From c2255224663af6f73aa7b337598738c76d66d966 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Sun, 13 Sep 2026 18:52:51 -0600 Subject: [PATCH 01/38] build: migrate packaging and application to ctui 1.0.0rc1 --- MIGRATION.md | 23 ++ VERSION | 1 - pyproject.toml | 16 +- setup.py | 73 ----- src/ctmodbus/app.py | 15 + src/ctmodbus/commands.py | 341 +-------------------- uv.lock | 645 ++++----------------------------------- 7 files changed, 111 insertions(+), 1003 deletions(-) create mode 100644 MIGRATION.md delete mode 100644 VERSION delete mode 100644 setup.py create mode 100644 src/ctmodbus/app.py diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..9e2b978 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,23 @@ +# ctmodbus 1.0 migration + +Target: ctui 1.0.0rc1, Python 3.11+, native async PyModbus. +No compatibility layer for pre-1.0 commands or storage. + +Commit milestones: + +1. Packaging and application foundation. +2. Async connection lifecycle and discovery. +3. Typed commands, range reads, and acknowledged writes. +4. Project profiles, records, progress, and cancellation. +5. Automated regression and transport integration coverage. +6. Documentation, CI, distribution validation, and release checklist. + +Retain TCP/UDP/RTU/ASCII, discovery, identification, four read types, and +coil/register writes. Repair existing multi-write and response-validation gaps. +Defer TLS client, polling, tags, simulation, proxying, raw/fuzzy requests, +tunneling, and historian integration. + +Release gates: command parity in TUI and CLI; nonblocking I/O; serialized +connection operations; bounded shutdown; project isolation; regression and +local transport tests; installed wheel/sdist smoke tests. Serial hardware and +interactive terminal checks must be recorded separately from automation. 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/pyproject.toml b/pyproject.toml index b5f5fdb..739fb09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,12 @@ [project] name = "ctmodbus" -version = "0.6.0" +version = "1.0.0rc1" 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.0rc1", "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] 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/app.py b/src/ctmodbus/app.py new file mode 100644 index 0000000..f0aaae6 --- /dev/null +++ b/src/ctmodbus/app.py @@ -0,0 +1,15 @@ +"""Application ownership and ctui lifecycle integration.""" + +from importlib.metadata import version + +from ctui import CtuiApp + + +class ModbusApp(CtuiApp): + """A Modbus client with one connection and project-scoped services.""" + + name = "ctmodbus" + version = version("ctmodbus") + description = "An asynchronous Modbus tool for device testing" + prompt = "ctmodbus> " + app_id = "io.controlthings.ctmodbus" diff --git a/src/ctmodbus/commands.py b/src/ctmodbus/commands.py index 4cf241b..bb3c73d 100644 --- a/src/ctmodbus/commands.py +++ b/src/ctmodbus/commands.py @@ -1,344 +1,11 @@ -""" -Control Things Modbus, aka ctmodbus.py +"""Console entry point.""" -# 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 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" - - -@ctmodbus.command -def do_read(): - """Various modbus read functions...""" - - -@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 - """ - 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 +from ctmodbus.app import ModbusApp def main(): - ctmodbus.run() + """Run the terminal UI or ctui's automatic command-line interface.""" + ModbusApp().run() if __name__ == "__main__": diff --git a/uv.lock b/uv.lock index f3aaffd..13f758c 100644 --- a/uv.lock +++ b/uv.lock @@ -1,174 +1,43 @@ version = 1 revision = 3 -requires-python = ">=3.8, <4" +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 = "aiosqlite" +version = "0.22.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/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/80/96/b32bbbb46170a1c8b8b1f28c794202e25cfe743565e9d3469b8eb1e0cc05/astroid-3.2.4-py3-none-any.whl", hash = "sha256:413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25", size = 276348, upload-time = "2024-07-20T12:57:40.886Z" }, -] - -[[package]] -name = "astroid" -version = "3.3.11" -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" } -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/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 = "astroid" version = "4.0.4" 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'" }, -] 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" }, ] -[[package]] -name = "black" -version = "24.8.0" -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" }, -] - -[[package]] -name = "black" -version = "25.11.0" -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.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 = "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 +61,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 +84,28 @@ wheels = [ [[package]] name = "ctmodbus" -version = "0.6.0" +version = "1.0.0rc1" 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.0rc1" }, + { 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 +119,33 @@ dev = [ [[package]] name = "ctui" -version = "0.8.1" +version = "1.0.0rc1" 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 = "aiosqlite" }, + { name = "platformdirs" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, { 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'" }, -] -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" } -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" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/de/7f91dcb8e99933dcfb07639ce29dcd6b4b1dacec441ee092b544ef6e437e/ctui-1.0.0rc1.tar.gz", hash = "sha256:81400752d39fefa845349b232adf928ff102a751692a8bce7b42f21c8ec401eb", size = 61260, upload-time = "2026-09-14T00:28:42.644Z" } 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/f0/15/a05b4c3590bfce406d79cc89363fc0e01290b531893e64a5d46f84d80539/ctui-1.0.0rc1-py3-none-any.whl", hash = "sha256:4a3f58f36805ba37ffa012a5385d28392c3a4a0c18515d937faa0ac33929b207", size = 63726, upload-time = "2026-09-14T00:28:41.084Z" }, ] [[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" -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" } -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" }, -] - -[[package]] -name = "isort" -version = "5.13.2" -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" } -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" }, -] - [[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" }, @@ -392,123 +169,37 @@ 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" }, ] -[[package]] -name = "packaging" -version = "26.2" -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" } -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" }, -] - [[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" }, ] @@ -519,109 +210,53 @@ wheels = [ [[package]] name = "psutil" -version = "5.9.8" -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" } -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" }, -] - -[[package]] -name = "pygments" -version = "2.19.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" } -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" }, +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +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/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 +265,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 +292,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 +317,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,139 +329,15 @@ 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" }, -] - [[package]] name = "wcwidth" version = "0.8.2" @@ -858,12 +346,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288be wheels = [ { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, ] - -[[package]] -name = "zipp" -version = "3.23.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" } -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" }, -] From 39d063377adad311c75137a5ea5930fad6216c0d Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Sun, 13 Sep 2026 18:55:09 -0600 Subject: [PATCH 02/38] feat: add native async Modbus transports and connection lifecycle --- src/ctmodbus/app.py | 142 ++++++++++++++++++++++++++++- src/ctmodbus/connection.py | 179 +++++++++++++++++++++++++++++++++++++ src/ctmodbus/discovery.py | 51 +++++++++++ 3 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 src/ctmodbus/connection.py create mode 100644 src/ctmodbus/discovery.py diff --git a/src/ctmodbus/app.py b/src/ctmodbus/app.py index f0aaae6..43cd6cf 100644 --- a/src/ctmodbus/app.py +++ b/src/ctmodbus/app.py @@ -1,8 +1,31 @@ """Application ownership and ctui lifecycle integration.""" +import asyncio from importlib.metadata import version +from typing import Literal -from ctui import CtuiApp +from ctui import Argument, CommandResult, CtuiApp, command + +from ctmodbus.connection import Connection, ConnectionSettings, create_client +from ctmodbus.discovery import complete_serial, suggestions + +NETWORK_ARGUMENTS = { + name: Argument(flags=(f"--{name}",)) + for name in ("port", "unit", "timeout", "retries") +} +SERIAL_ARGUMENTS = { + name: Argument(flags=(f"--{name}",)) + for name in ( + "unit", + "timeout", + "retries", + "baudrate", + "bytesize", + "parity", + "stopbits", + ) +} +SERIAL_ARGUMENTS["device"] = Argument(completer=complete_serial) class ModbusApp(CtuiApp): @@ -13,3 +36,120 @@ class ModbusApp(CtuiApp): description = "An asynchronous Modbus tool for device testing" prompt = "ctmodbus> " app_id = "io.controlthings.ctmodbus" + + def __init__(self, *, client_factory=create_client, **kwargs): + super().__init__(**kwargs) + self.connection = Connection(client_factory) + self.statusbar = self.connection_status + + def connection_status(self): + settings = self.connection.settings + return ( + f"{settings.label} ({'connected' if self.connection.connected else 'disconnected'})" + if settings + else "Disconnected" + ) + + async def on_stop(self): + await self.connection.close() + + @command(name="connect") + async def connect_suggestions(self): + """List local serial devices and listening services.""" + return await asyncio.to_thread(suggestions) + + async def open_connection(self, settings): + await self.connection.connect(settings) + return CommandResult.append(f"Session OPENED: {settings.label}") + + @command(name="connect tcp", arguments=NETWORK_ARGUMENTS) + async def connect_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="connect udp", arguments=NETWORK_ARGUMENTS) + async def connect_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="connect rtu", arguments=SERIAL_ARGUMENTS) + async def connect_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="connect ascii", arguments=SERIAL_ARGUMENTS) + async def connect_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 + async def close(self): + """Close the session and cancel outstanding device operations.""" + await self.connection.close() + return CommandResult.append("Session CLOSED") diff --git a/src/ctmodbus/connection.py b/src/ctmodbus/connection.py new file mode 100644 index 0000000..d68527d --- /dev/null +++ b/src/ctmodbus/connection.py @@ -0,0 +1,179 @@ +"""Native async transports and serialized, cancellable connection ownership.""" + +import asyncio +import math +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, + AsyncModbusUdpClient, +) +from pymodbus.exceptions import ModbusException + +Transport = Literal["tcp", "udp", "rtu", "ascii"] + + +@dataclass(frozen=True) +class ConnectionSettings: + """Serializable settings; never persist a live transport.""" + + 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 + + def __post_init__(self): + if self.transport not in ("tcp", "udp", "rtu", "ascii"): + raise CommandError("Transport must be tcp, udp, 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") + + def as_dict(self): + return asdict(self) + + @property + def label(self): + target = ( + f"{self.target}:{self.port}" + if self.transport in ("tcp", "udp") + else self.target + ) + return f"{self.transport.upper()} {target} unit {self.unit}" + + +def create_client(settings): + """Construct a client in the running event loop; disable implicit reconnects.""" + options = dict( + timeout=settings.timeout, retries=settings.retries, reconnect_delay=0 + ) + 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, invalidate stale work, and serialize wire transactions.""" + + def __init__(self, client_factory=create_client): + 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): + return self.client is not None and self.client.connected + + async def connect(self, settings): + 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) + 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): + client, generation = self.client, self.generation + if client is None: + raise CommandError("No open session; connect 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): + """Cancel outstanding I/O before releasing the transport.""" + 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 + + async def request(self, method, **kwargs): + """Bound each request even if a transport stops responding.""" + 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/discovery.py b/src/ctmodbus/discovery.py new file mode 100644 index 0000000..4e853f3 --- /dev/null +++ b/src/ctmodbus/discovery.py @@ -0,0 +1,51 @@ +"""Local connection suggestions, performed off the event loop.""" + +import asyncio + +import psutil +from serial.tools.list_ports import comports +from tabulate import tabulate + + +def serial_devices(): + return sorted(comports(), key=lambda item: item.device) + + +async def complete_serial(context): + """ctui async completion provider; discovery is advisory, not a restriction.""" + return [item.device for item in await asyncio.to_thread(serial_devices)] + + +def suggestions(): + 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) From b5dabf874fdf739b731740764327973e56664ca4 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Sun, 13 Sep 2026 19:00:47 -0600 Subject: [PATCH 03/38] feat: migrate reads and writes to validated async commands --- pyproject.toml | 3 + src/ctmodbus/app.py | 3 +- src/ctmodbus/common.py | 293 ------------------------------------- src/ctmodbus/connection.py | 7 + src/ctmodbus/formatting.py | 59 ++++++++ src/ctmodbus/operations.py | 284 +++++++++++++++++++++++++++++++++++ 6 files changed, 355 insertions(+), 294 deletions(-) delete mode 100644 src/ctmodbus/common.py create mode 100644 src/ctmodbus/formatting.py create mode 100644 src/ctmodbus/operations.py diff --git a/pyproject.toml b/pyproject.toml index 739fb09..72cc5a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,9 @@ 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" diff --git a/src/ctmodbus/app.py b/src/ctmodbus/app.py index 43cd6cf..f1aae08 100644 --- a/src/ctmodbus/app.py +++ b/src/ctmodbus/app.py @@ -8,6 +8,7 @@ from ctmodbus.connection import Connection, ConnectionSettings, create_client from ctmodbus.discovery import complete_serial, suggestions +from ctmodbus.operations import ModbusCommands NETWORK_ARGUMENTS = { name: Argument(flags=(f"--{name}",)) @@ -28,7 +29,7 @@ SERIAL_ARGUMENTS["device"] = Argument(completer=complete_serial) -class ModbusApp(CtuiApp): +class ModbusApp(ModbusCommands, CtuiApp): """A Modbus client with one connection and project-scoped services.""" name = "ctmodbus" 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/connection.py b/src/ctmodbus/connection.py index d68527d..819fadd 100644 --- a/src/ctmodbus/connection.py +++ b/src/ctmodbus/connection.py @@ -166,6 +166,13 @@ async def close(self): self.client.close() self.client = self.settings = None + def abort(self): + """Discard the transport after interrupted I/O and reject queued work.""" + self.generation += 1 + if self.client is not None: + self.client.close() + self.client = self.settings = None + async def request(self, method, **kwargs): """Bound each request even if a transport stops responding.""" settings = self.settings diff --git a/src/ctmodbus/formatting.py b/src/ctmodbus/formatting.py new file mode 100644 index 0000000..7fefd81 --- /dev/null +++ b/src/ctmodbus/formatting.py @@ -0,0 +1,59 @@ +"""Pure formatting for identical TUI and command-line results.""" + +from datetime import datetime, timezone +from itertools import groupby + +from tabulate import tabulate + + +def timestamp(): + return datetime.now(timezone.utc).isoformat(timespec="milliseconds") + + +def format_values(kind, values): + """Summarize consecutive equal values without hiding address gaps or order.""" + 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): + 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/operations.py b/src/ctmodbus/operations.py new file mode 100644 index 0000000..b6d0b14 --- /dev/null +++ b/src/ctmodbus/operations.py @@ -0,0 +1,284 @@ +"""Typed Modbus commands, validation, and response handling.""" + +import asyncio + +from ctui import ( + Argument, + CommandError, + CommandResult, + IntegerRanges, + IntegerSpan, + command, +) + +from ctmodbus.formatting import format_identification, format_values, timestamp + +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): + """Validate the entire request before yielding exclusive-endpoint spans.""" + 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): + 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}") + + +class ModbusCommands: + """Commands mixed into ModbusApp; connection and services belong to the app.""" + + async def record_operation(self, direction, decoded): + """Overridden by the application to persist decoded protocol records.""" + + async def read_values(self, kind, addresses, max_count): + chunks = list(read_chunks(addresses, max_count, READ_LIMITS[kind])) + results = [] + try: + async with self.connection.operation() as (client, settings): + 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, + ) + 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 CommandResult.append( + f"{timestamp()} Read {kind}\n{format_values(kind, results)}" + ) + + @staticmethod + def partial_read(kind, results, message): + if results: + return f"{message}\nPartial read: {len(results)} addresses completed\n{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): + """Read basic device identification, including continued responses.""" + 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") + 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): + 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 ( + response.function_code != expected_function + or response.address != address + ): + raise CommandError( + "Write acknowledgement has incorrect function or address" + ) + if multiple: + if response.count != len(values): + raise CommandError("Write acknowledgement has incorrect count") + else: + echoed = response.bits if kind == "coils" else response.registers + 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}) + 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}) + raise CommandError(message) from error + return CommandResult.append( + f"{timestamp()} Write acknowledged: {kind}\n{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") + async def write_holding_registers(self, address: int, values: list[int]): + """Write one or more registers as comma-separated integers.""" + return await self.write_values("holding_registers", address, values) From c6054e2d7d3a8b34ff30e3402a8fdc844f10160b Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Sun, 13 Sep 2026 19:05:33 -0600 Subject: [PATCH 04/38] feat: integrate project profiles, records, progress, and cancellation --- src/ctmodbus/app.py | 171 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 167 insertions(+), 4 deletions(-) diff --git a/src/ctmodbus/app.py b/src/ctmodbus/app.py index f1aae08..d4f9c28 100644 --- a/src/ctmodbus/app.py +++ b/src/ctmodbus/app.py @@ -1,10 +1,11 @@ """Application ownership and ctui lifecycle integration.""" import asyncio +from dataclasses import replace from importlib.metadata import version from typing import Literal -from ctui import Argument, CommandResult, CtuiApp, command +from ctui import Argument, CommandError, CommandResult, CtuiApp, command from ctmodbus.connection import Connection, ConnectionSettings, create_client from ctmodbus.discovery import complete_serial, suggestions @@ -29,6 +30,10 @@ SERIAL_ARGUMENTS["device"] = Argument(completer=complete_serial) +async def complete_profiles(context): + return list(await context.app.configs.list()) + + class ModbusApp(ModbusCommands, CtuiApp): """A Modbus client with one connection and project-scoped services.""" @@ -42,17 +47,138 @@ def __init__(self, *, client_factory=create_client, **kwargs): super().__init__(**kwargs) self.connection = Connection(client_factory) self.statusbar = self.connection_status + self._device_dispatches = set() + self._project_changing = False + self._closing = 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 connection_status(self): settings = self.connection.settings - return ( + state = ( f"{settings.label} ({'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}" + + def update_progress(self, completed, total): + self._progress = f" | Read {completed}/{total}" if total else "" + if hasattr(self, "app"): + self.app.invalidate() + + async def dispatch(self, text, **kwargs): + # 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( + ("connect ", "read ", "write ", "profile ") + ) + task = asyncio.current_task() + if self._stopping: + raise CommandError("Application is stopping") + if project_change: + if ( + self._project_changing + or self._device_dispatches + 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" + ) + if device_command: + if self._closing: + raise CommandError("Connection is closing; retry when it finishes") + self._device_dispatches.add(task) + try: + result = await super().dispatch(text, **kwargs) + 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 + finally: + if project_change: + self._project_changing = False + if device_command: + self._device_dispatches.discard(task) + self._record_warnings.pop(task, None) + + async def record_operation(self, 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: + # 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): + 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): + async with self._close_lock: + self._closing = True + try: + pending = self._device_dispatches - {asyncio.current_task()} + for task in pending: + task.cancel() + if pending: + await asyncio.gather(*pending, return_exceptions=True) + await self.connection.close() + await self.finish_record_session() + finally: + self._closing = False + self.update_progress(0, 0) async def on_stop(self): - await self.connection.close() + self._stopping = True + await self.close_connection() @command(name="connect") async def connect_suggestions(self): @@ -61,6 +187,14 @@ async def connect_suggestions(self): async def open_connection(self, settings): await self.connection.connect(settings) + 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="connect tcp", arguments=NETWORK_ARGUMENTS) @@ -152,5 +286,34 @@ async def connect_ascii( @command async def close(self): """Close the session and cancel outstanding device operations.""" - await self.connection.close() + await self.close_connection() return CommandResult.append("Session CLOSED") + + @command + async def cancel(self): + """Cancel device work and close the connection to discard pending replies.""" + await self.close_connection() + return CommandResult.append("Device work cancelled; session CLOSED") + + @command(name="profile save") + async def profile_save(self, name: str): + """Save the current connection settings as a named project config.""" + if self.connection.settings is None: + raise CommandError("Connect before saving a profile") + await self.configs.save(name, self.connection.settings.as_dict()) + return CommandResult.append(f"Saved connection profile {name!r}") + + @command( + name="profile connect", + arguments={"name": Argument(completer=complete_profiles)}, + ) + async def profile_connect(self, name: str): + """Connect using a named project config.""" + values = await self.configs.get(name) + try: + settings = ConnectionSettings(**values) + except (TypeError, ValueError) as error: + raise CommandError( + f"Invalid connection profile {name!r}: {error}" + ) from error + return await self.open_connection(settings) From 500949fcc4f3c8651ba3948ffe5ee1383cdda924 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Sun, 13 Sep 2026 19:18:21 -0600 Subject: [PATCH 05/38] test: cover async commands, transports, and terminal behavior --- pyproject.toml | 3 +- src/ctmodbus/app.py | 58 ++++- src/ctmodbus/connection.py | 13 +- src/ctmodbus/discovery.py | 4 +- src/ctmodbus/formatting.py | 2 + src/ctmodbus/operations.py | 39 +++- tests/server-ascii.py | 47 ---- tests/server-rtu.py | 54 ----- tests/server-tcp.py | 45 ---- tests/server-tls.py | 51 ----- tests/server-udp.py | 45 ---- tests/server.py | 60 +++++ tests/test_commands.py | 457 +++++++++++++++++++++++++++++++++++++ tests/test_integration.py | 114 +++++++++ tests/test_tui.py | 50 ++++ 15 files changed, 778 insertions(+), 264 deletions(-) delete mode 100644 tests/server-ascii.py delete mode 100644 tests/server-rtu.py delete mode 100644 tests/server-tcp.py delete mode 100644 tests/server-tls.py delete mode 100644 tests/server-udp.py create mode 100644 tests/server.py create mode 100644 tests/test_commands.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_tui.py diff --git a/pyproject.toml b/pyproject.toml index 72cc5a0..25566f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,8 @@ build-backend = "uv_build" 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/src/ctmodbus/app.py b/src/ctmodbus/app.py index d4f9c28..beaafe4 100644 --- a/src/ctmodbus/app.py +++ b/src/ctmodbus/app.py @@ -9,7 +9,7 @@ from ctmodbus.connection import Connection, ConnectionSettings, create_client from ctmodbus.discovery import complete_serial, suggestions -from ctmodbus.operations import ModbusCommands +from ctmodbus.operations import ModbusCommandMixin NETWORK_ARGUMENTS = { name: Argument(flags=(f"--{name}",)) @@ -31,10 +31,11 @@ async def complete_profiles(context): + """Complete saved profile names from the active project.""" return list(await context.app.configs.list()) -class ModbusApp(ModbusCommands, CtuiApp): +class ModbusApp(ModbusCommandMixin, CtuiApp): """A Modbus client with one connection and project-scoped services.""" name = "ctmodbus" @@ -50,6 +51,7 @@ def __init__(self, *, client_factory=create_client, **kwargs): self._device_dispatches = set() self._project_changing = False self._closing = False + self._opening = False self._stopping = False self._close_lock = asyncio.Lock() self._record_session = None @@ -64,9 +66,11 @@ def __init__(self, *, client_factory=create_client, **kwargs): ) def connection_status(self): + """Describe project, transport state, and active read progress.""" settings = self.connection.settings state = ( - f"{settings.label} ({'connected' if self.connection.connected else 'disconnected'})" + f"{settings.label} " + f"({'connected' if self.connection.connected else 'disconnected'})" if settings else "Disconnected" ) @@ -78,11 +82,12 @@ def connection_status(self): return f"Project: {project} | {state}{self._progress}" def update_progress(self, completed, total): + """Refresh the TUI footer without affecting CLI output.""" self._progress = f" | Read {completed}/{total}" if total else "" if hasattr(self, "app"): self.app.invalidate() - async def dispatch(self, text, **kwargs): + async def dispatch(self, text, **kwargs): # pylint: disable=too-many-branches # 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) @@ -107,15 +112,28 @@ async def dispatch(self, text, **kwargs): or self._closing ): raise CommandError( - "Close the connection and finish device work before changing or resetting projects" + "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 { + "connect tcp", + "connect udp", + "connect rtu", + "connect ascii", + "profile connect", + } 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) try: @@ -131,11 +149,20 @@ async def dispatch(self, text, **kwargs): 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 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): @@ -148,7 +175,7 @@ async def record_operation(self, direction, decoded): session=self._record_session, decoded=decoded, ) - except Exception as error: + 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( @@ -156,26 +183,36 @@ async def record_operation(self, direction, decoded): ) async def finish_record_session(self): + """End recording before switching connections or closing project storage.""" 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): + """Drain device commands before transport and record-session cleanup.""" async with self._close_lock: self._closing = True try: pending = self._device_dispatches - {asyncio.current_task()} for task in pending: task.cancel() - if pending: - await asyncio.gather(*pending, return_exceptions=True) - await self.connection.close() - await self.finish_record_session() + try: + 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 on_start(self): + """Allow a fresh runtime to use this application instance.""" + self._stopping = False + async def on_stop(self): self._stopping = True await self.close_connection() @@ -186,6 +223,7 @@ async def connect_suggestions(self): return await asyncio.to_thread(suggestions) async def open_connection(self, settings): + """Open a transport and its project recording session together.""" await self.connection.connect(settings) try: await self.finish_record_session() diff --git a/src/ctmodbus/connection.py b/src/ctmodbus/connection.py index 819fadd..589cf5c 100644 --- a/src/ctmodbus/connection.py +++ b/src/ctmodbus/connection.py @@ -63,10 +63,12 @@ def __post_init__(self): raise CommandError("parity must be N, E, or O") 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") @@ -77,9 +79,11 @@ def label(self): def create_client(settings): """Construct a client in the running event loop; disable implicit reconnects.""" - options = dict( - timeout=settings.timeout, retries=settings.retries, reconnect_delay=0 - ) + options = { + "timeout": settings.timeout, + "retries": settings.retries, + "reconnect_delay": 0, + } if settings.transport == "tcp": return AsyncModbusTcpClient(settings.target, port=settings.port, **options) if settings.transport == "udp": @@ -108,9 +112,11 @@ def __init__(self, client_factory=create_client): @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): + """Create and open a client, discarding failed or cancelled attempts.""" task = asyncio.current_task() generation = self.generation self.tasks.add(task) @@ -138,6 +144,7 @@ async def connect(self, settings): @asynccontextmanager async def operation(self): + """Reserve the current session for a complete command's requests.""" client, generation = self.client, self.generation if client is None: raise CommandError("No open session; connect first") diff --git a/src/ctmodbus/discovery.py b/src/ctmodbus/discovery.py index 4e853f3..e2f1b3e 100644 --- a/src/ctmodbus/discovery.py +++ b/src/ctmodbus/discovery.py @@ -8,15 +8,17 @@ def serial_devices(): + """Enumerate ports in a stable display order.""" return sorted(comports(), key=lambda item: item.device) -async def complete_serial(context): +async def complete_serial(_context): """ctui async completion provider; discovery is advisory, not a restriction.""" return [item.device for item in await asyncio.to_thread(serial_devices)] def suggestions(): + """Format local devices and best-effort process inspection.""" devices = [ [item.device, item.manufacturer or "", item.product or ""] for item in serial_devices() diff --git a/src/ctmodbus/formatting.py b/src/ctmodbus/formatting.py index 7fefd81..81b5c43 100644 --- a/src/ctmodbus/formatting.py +++ b/src/ctmodbus/formatting.py @@ -7,6 +7,7 @@ def timestamp(): + """Return an unambiguous UTC timestamp for protocol output.""" return datetime.now(timezone.utc).isoformat(timespec="milliseconds") @@ -38,6 +39,7 @@ def format_values(kind, values): def format_identification(information): + """Label sparse device objects and escape terminal control characters.""" names = ( "VendorName", "ProductCode", diff --git a/src/ctmodbus/operations.py b/src/ctmodbus/operations.py index b6d0b14..9111009 100644 --- a/src/ctmodbus/operations.py +++ b/src/ctmodbus/operations.py @@ -38,6 +38,7 @@ def read_chunks(addresses, max_count, limit): def validate_write(kind, address, values): + """Reject invalid writes before issuing any protocol request.""" limit = 1968 if kind == "coils" else 123 maximum = 1 if kind == "coils" else 65535 if not values or len(values) > limit: @@ -48,13 +49,14 @@ def validate_write(kind, address, values): raise CommandError(f"{kind} values must be integers between 0 and {maximum}") -class ModbusCommands: +class ModbusCommandMixin: """Commands mixed into ModbusApp; connection and services belong to the app.""" async def record_operation(self, direction, decoded): """Overridden by the application to persist decoded protocol records.""" async def read_values(self, kind, addresses, max_count): + """Read ordered chunks and retain completed results on failure.""" chunks = list(read_chunks(addresses, max_count, READ_LIMITS[kind])) results = [] try: @@ -72,6 +74,14 @@ async def read_values(self, kind, addresses, max_count): 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" ) @@ -131,8 +141,12 @@ async def read_values(self, kind, addresses, max_count): @staticmethod def partial_read(kind, results, message): + """Include completed addresses in a failed read's error output.""" if results: - return f"{message}\nPartial read: {len(results)} addresses completed\n{format_values(kind, 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) @@ -183,6 +197,13 @@ async def read_id(self): 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", @@ -214,6 +235,7 @@ async def read_id(self): ) async def write_values(self, kind, address, values): + """Issue one write and verify the returned function, address and values.""" validate_write(kind, address, values) multiple = len(values) > 1 suffix = "coils" if kind == "coils" else "registers" @@ -240,17 +262,19 @@ async def write_values(self, kind, address, values): else (16 if multiple else 6) ) if ( - response.function_code != expected_function - or response.address != address + 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 response.count != len(values): + if getattr(response, "count", None) != len(values): raise CommandError("Write acknowledgement has incorrect count") else: - echoed = response.bits if kind == "coils" else response.registers + 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( @@ -270,7 +294,8 @@ async def write_values(self, kind, address, values): await self.record_operation("error", {**request, "error": message}) raise CommandError(message) from error return CommandResult.append( - f"{timestamp()} Write acknowledged: {kind}\n{format_values(kind, list(enumerate(values, address)))}" + f"{timestamp()} Write acknowledged: {kind}\n" + f"{format_values(kind, list(enumerate(values, address)))}" ) @command(name="write coils") 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..6d1f9c9 --- /dev/null +++ b/tests/server.py @@ -0,0 +1,60 @@ +"""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(): + 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): + 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(): + 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/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..32ab4e5 --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,457 @@ +"""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 SimpleNamespace(isError=lambda: False, **kwargs) + + +class FakeClient: + def __init__(self): + 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): + self.connected = True + return True + + def close(self): + self.connected = False + + def __getattr__(self, name): + async def call(**kwargs): + 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): + async def asyncSetUp(self): + 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): + await self.app.on_stop() + await self.app.backend.close() + self.directory.cleanup() + + async def connect(self): + await self.app.dispatch("connect tcp localhost --unit 7") + + async def test_all_transport_commands(self): + for transport in ("tcp", "udp", "rtu", "ascii"): + await self.app.dispatch( + f"connect {transport} target --unit 7 --timeout 0.2" + ) + self.assertEqual(self.app.connection.settings.transport, transport) + await self.app.dispatch("close") + + async def test_range_chunking_and_unit(self): + 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): + 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): + 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_invalid_arguments_do_not_send(self): + 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): + 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): + 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): + 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): + 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): + 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): + 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"), 1) + self.assertIn("read 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): + 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("cancel"), 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): + 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("close") + with self.assertRaisesRegex(CommandError, "outcome unknown"): + await task + + async def test_cancel_connect(self): + started = asyncio.Event() + + async def blocked(): + started.set() + await asyncio.Event().wait() + + self.client.connect = blocked + task = asyncio.create_task(self.app.dispatch("connect tcp localhost")) + await started.wait() + await asyncio.wait_for(self.app.dispatch("cancel"), 1) + with self.assertRaises(CommandError): + await task + self.assertIsNone(self.app.connection.client) + + async def test_shutdown_cancels_io(self): + 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): + 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): + await self.connect() + with self.assertRaisesRegex(CommandError, "already open"): + await self.connect() + + async def test_disconnected_session(self): + 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): + await self.connect() + await self.app.dispatch("profile 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("close") + 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("profile connect lab") + 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): + entered, release = asyncio.Event(), asyncio.Event() + original = self.app.records.start_session + + async def slow_start(*args, **kwargs): + 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("connect 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): + 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): + from pathlib import Path + + with tempfile.TemporaryDirectory() as path: + script = Path(path) / "commands.txt" + script.write_text( + "# local fixture\n\nconnect 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", "connect tcp localhost", "-c", "write coils 0 nope"], + stdout=out, + stderr=err, + ) + self.assertEqual(status, 2) + self.assertFalse(self.client.connected) + + async def test_invalid_profile(self): + await self.app.configs.save( + "bad", {"transport": "tcp", "target": "localhost", "timeout": "oops"} + ) + with self.assertRaises(CommandError): + await self.app.dispatch("profile connect bad") + + async def test_record_failure_keeps_acknowledged_write(self): + 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. + 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", + "connect 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): + def test_range_endpoints_and_order(self): + 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): + 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): + 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_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..1583c58 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,114 @@ +"""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): + async def exercise(self, 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): + 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"connect {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): + await self.run_network("tcp") + + async def test_udp(self): + 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. + 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): + 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"connect {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): + await self.run_serial("rtu") + + @unittest.skipUnless(os.name == "posix", "PTY serial tests require POSIX") + async def test_ascii(self): + await self.run_serial("ascii") diff --git a/tests/test_tui.py b/tests/test_tui.py new file mode 100644 index 0000000..1e4b875 --- /dev/null +++ b/tests/test_tui.py @@ -0,0 +1,50 @@ +"""Exercise the actual ctui terminal runtime with an injected terminal.""" + +import asyncio +import tempfile +import unittest + +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): + async def test_terminal_commands_and_shutdown(self): + ready = asyncio.Event() + finished = asyncio.Queue() + + class TestApp(ModbusApp): + async def on_ready(self): + 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 ( + ("connect tcp localhost", "connect 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 + ) + pipe.send_text("exit\n") + await asyncio.wait_for(running, 3) + self.assertFalse(client.connected) + finally: + if not running.done(): + app.exit() + await running From 7914437744c5aeaf5497aaf29be1e92e5da20694 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Sun, 13 Sep 2026 19:26:20 -0600 Subject: [PATCH 06/38] ci: validate v1.0.0rc1 releases and document the migration --- .github/workflows/publish.yml | 12 +- .github/workflows/test.yml | 50 +++++++++ .gitignore | 3 +- CHANGELOG.md | 21 ++++ MIGRATION.md | 23 ++++ README.md | 202 ++++++++++++++++++++++++---------- RELEASE_CHECKLIST.md | 31 ++++++ src/ctmodbus/app.py | 4 + tests/smoke_test.py | 29 +++++ tests/test_commands.py | 12 ++ 10 files changed, 323 insertions(+), 64 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 CHANGELOG.md create mode 100644 RELEASE_CHECKLIST.md create mode 100644 tests/smoke_test.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index aa414dc..a87f0b7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,7 +9,13 @@ on: - "v[0-9]+.[0-9]+.[0-9]+[ab][0-9]+" jobs: + checks: + uses: ./.github/workflows/test.yml + permissions: + contents: read + build: + needs: checks runs-on: ubuntu-latest permissions: contents: read @@ -27,12 +33,6 @@ jobs: - 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: Upload distributions as artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..aa52e5e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,50 @@ +name: Tests + +on: + pull_request: + push: + branches: ["**"] + workflow_call: + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python: ["3.11", "3.12", "3.13", "3.14"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 + with: + python-version: ${{ matrix.python }} + enable-cache: false + - run: uv sync --locked + - run: uv run python -m unittest discover -s tests -v + - run: uv run black --check src tests + - run: uv run isort --check-only src tests + - run: uv run pylint src/ctmodbus + + distributions: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 + with: + python-version: "3.11" + enable-cache: false + - run: uv build + - name: Test installed distributions + shell: bash + run: | + for artifact in dist/*.whl dist/*.tar.gz; do + uv run --isolated --no-project --with "$artifact" tests/smoke_test.py + done 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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8813a5b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +## 1.0.0rc1 — unreleased + +- Migrate from ctui 0.x to ctui 1.0.0rc1 and require Python 3.11+. +- Use native async PyModbus 3.15 clients for TCP, UDP, RTU, and ASCII. +- Replace global runtime state with an application-owned connection lifecycle. +- Adopt typed commands, inclusive integer ranges, named options, automatic CLI, + completion, project history, connection profiles, and decoded protocol records. +- Serialize device operations, report read progress, and add explicit cancellation. +- Check response lengths and write acknowledgements; report partial reads and + uncertain writes; repair multi-value writes and sparse device identification. +- Replace obsolete manual server scripts with one current fixture and automated + command, transport, persistence, cancellation, and terminal tests. +- Consolidate packaging and align license metadata with the GPL-3.0-or-later source. + +Breaking changes: no pre-1.0 API, command, or storage compatibility. Commands use +plural snake_case data names, comma-separated write values, and separate host +and `--port` arguments. Read counts are expressed as inclusive address ranges; +`--max-count` controls request chunk size. Remove the development `debug eval` +command. Python 3.8–3.10 are no longer supported. diff --git a/MIGRATION.md b/MIGRATION.md index 9e2b978..11a7a5b 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -21,3 +21,26 @@ Release gates: command parity in TUI and CLI; nonblocking I/O; serialized connection operations; bounded shutdown; project isolation; regression and local transport tests; installed wheel/sdist smoke tests. Serial hardware and interactive terminal checks must be recorded separately from automation. + +## Implemented + +All six milestones are implemented. The package remains at 1.0.0rc1 with +ctui==1.0.0rc1 and pymodbus[serial]>=3.15.0,<3.16 (locked to 3.15.0). +The new README documents the breaking command syntax and connection defaults. + +Local validation completed on Linux: + +- 34 automated tests passed on Python 3.11 and Python 3.14. +- Actual TCP/UDP and bridged-PTY RTU/ASCII exchanges passed. +- Injected-terminal TUI and sequential CLI/command-file execution passed. +- Cancellation, write uncertainty, project isolation, and recording failures + have regression coverage. +- Black, isort, pylint, and lockfile validation passed. +- Wheel and source distribution built; both passed isolated installation, + entry-point help, and project/config smoke checks. + +The GitHub workflow defines Python 3.11–3.14 checks on Linux, macOS, and Windows, +with distribution smoke tests required before publication. Those remote jobs +have not been run from this workspace. Physical serial hardware and real +terminal usability checks remain on RELEASE_CHECKLIST.md. No release was +published and no tags were pushed. diff --git a/README.md b/README.md index e465425..ef0fe89 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,172 @@ # ControlThings Modbus -A highly flexible Modbus tool made for penetration testers. +An asynchronous Modbus tool for device testing, built with +[ctui](https://github.com/ControlThings-io/ctui) and +[PyModbus](https://github.com/pymodbus-dev/pymodbus). -# Installation: +The 1.0 release candidate supports TCP, UDP, serial RTU, and serial ASCII; +device identification; coils, discrete inputs, input registers, and holding +registers; and single/multiple coil and holding-register writes. -As long as you have git and Python 3.8 or later installed, all you should need to do is: +## Installation + +Requires Python 3.11 or newer. Install the release candidate from this checkout: ```bash -pip3 install ctmodbus +uv tool install . ``` -Or better yet, if you have `uv` installed, install it in an isolated environment with: +For development: ```bash -uv tool install ctmodbus +uv sync --locked +uv run ctmodbus ``` -`uv` also lets you try the tool out without installing it by running: - -```bash -uvx ctmodbus +After a release candidate has been published, install it with +`uv tool install 'ctmodbus==1.0.0rc1'` or +`python -m pip install 'ctmodbus==1.0.0rc1'`. + +## Interactive commands + +Run `ctmodbus` without arguments to open the terminal interface. Commands have +automatic completion, generated help, and unique-prefix matching. Use `help` +for the full list, including ctui's project, config, and history commands. + +```text +connect # suggest serial ports and local services +connect tcp 10.10.10.1 --port 502 --unit 1 +connect udp 10.10.10.1 --port 10502 +connect rtu /dev/ttyUSB0 --baudrate 9600 --parity E +connect ascii COM2 --baudrate 9600 +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 +close ``` -## Examples of current user interface commands once you start ctmodbus: +These examples show alternative connections: close the current session before +opening another. Serial-device discovery is advisory; device paths and symlinks +can be supplied even if enumeration does not list them. UDP opening establishes +a local socket and does not prove that a remote Modbus device is present. + +Addresses are **zero-based wire addresses**, from 0 through 65535, rather than +4xxxx reference numbers. Ranges are inclusive and preserve input order and +repetitions. A read accepts at most 65,536 addresses including repetitions. +`--max-count` limits each request, not the total number of addresses: up to +2,000 for bits and 125 for registers. Use `50-58` to read nine addresses at 50. + +Writes accept comma-separated integers: 0/1 for coils and 0–65535 for registers. +One value selects a single write; multiple values select one multiple-write +request (at most 1,968 coils or 123 registers). Writes never split automatically. +Success means the device returned a matching acknowledgement, not an independent +readback. Failed or interrupted writes may have reached the device; an +unconfirmed outcome is reported explicitly. + +All connections accept `--unit` (1–247), `--timeout` (seconds), and `--retries` +(0–10). Defaults are unit 1, zero retries, and a timeout of 3 seconds for network +connections or 1 second for serial connections. Serial defaults are 9600 baud, +8 data bits, no parity, and 1 stop bit; override these with `--baudrate`, +`--bytesize`, `--parity`, and `--stopbits`. Network ports default to 502; supply +host and port separately, including for IPv6 addresses. + +Device I/O is asynchronous, with requests serialized on the single connection. +The status bar shows the project, transport state, and read progress. `cancel` +or `close` cancels outstanding device work and closes the transport. Reconnect +before issuing more requests. The tool does not automatically reconnect. +Ctrl-C clears the input line; use `cancel` to stop running device commands. + +Read output includes UTC timestamps and compressed address/value summaries. +Register output includes integer, hex, and character views; the character view +is a Unicode code point per register, not a UTF-8 string decoder. Failed chunked +reads include the completed values in the error output. + +## Command-line execution + +The same commands work without opening the terminal 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 'connect tcp 127.0.0.1 --port 5020' \ + -c 'read holding_registers 0-9' -c 'close' +ctmodbus -f commands.txt ``` -## Planned UI commands once complete: - -```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 +Repeat `-c`/`--command` and `-f`/`--file` in the desired order. Command files +contain one command per line; blank lines and lines starting with `#` are +ignored. Quote complete commands at the shell. Inline comments in the +interactive examples above are explanatory and should be omitted when typing. + +Commands execute sequentially in CLI mode and stop at the first error. Exit +status is 0 for success, 2 for command/argument/protocol errors, and 1 for +unexpected command failures. Shutdown closes the connection automatically. + +## Projects, profiles, and records + +ctui stores projects in its platform-specific user data directory under the +application ID `io.controlthings.ctmodbus`. Each project contains named configs, +command history, connection recording sessions, and decoded Modbus operation +records. Live connections are never persisted. + +```text +project +configs list +configs show tcp-local +profile connect tcp-local +profile save lab +read holding_registers 0-9 +close +project export lab.ctui-project +history export commands.txt +project create another-lab +project load default +profile connect lab ``` -## This tool is built upon these to key library: +`tcp-local` and `udp-local` are built-in localhost profiles. `profile save NAME` +saves the current settings; `profile connect NAME` validates and loads them. +ctui also supplies config JSON import/export and whole-project import/export. +Close the connection and finish device work before switching or resetting +projects; this keeps protocol records in the project that initiated them. -- [Control Things User Interface](https://github.com/ControlThingsTools/ctui) -- [PyModbus](https://github.com/bashwork/pymodbus) +Records contain attempted requests, decoded responses, and operation errors, +linked to a session containing connection settings. They are not raw packet +captures. Recording errors are reported separately so an acknowledged write is +not mistaken for a failed write and retried. Command history follows ctui's +successful-command behavior; protocol errors are retained as operation records. + +## Development and validation + +```bash +uv run 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 build +``` +Tests use isolated temporary projects, injected clients, actual local TCP/UDP +servers, and a terminal runtime with injected input/output. POSIX systems also +exercise RTU/ASCII over bridged pseudo-terminals; Windows skips those PTY tests. +For a local manual TCP fixture, run `uv run python tests/server.py tcp --port 5020`. +The same fixture supports `udp`, `rtu`, and `ascii`; serial fixtures take +`--target DEVICE`. Physical serial adapters still need hardware smoke testing. -## Copyright 2025 Justin Searle +This is a breaking migration from 0.x. Legacy command spellings, space-separated +multi-write values, host:port syntax, and old storage formats are not supported. +Polling, tags, TLS client support, simulation, proxies, raw/fuzzy requests, +tunneling, and historian integration remain deferred. -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. +See [CHANGELOG.md](CHANGELOG.md), [MIGRATION.md](MIGRATION.md), and +[RELEASE_CHECKLIST.md](RELEASE_CHECKLIST.md) for scope and release gates. -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. +## License -You should have received a copy of the GNU General Public License along with this program. If not, see . +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..191123f --- /dev/null +++ b/RELEASE_CHECKLIST.md @@ -0,0 +1,31 @@ +# Release checklist + +## Automated gates + +- [ ] `uv lock --check` +- [ ] `uv run black --check src tests` +- [ ] `uv run isort --check-only src tests` +- [ ] `uv run pylint src/ctmodbus` +- [ ] `uv run python -m unittest discover -s tests -v` +- [ ] CI green on Linux, 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 --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. +- [ ] 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. +- [ ] Before final 1.0.0: adopt the published stable ctui 1.0 release, regenerate + the lockfile, update ctmodbus version and changelog, and rerun all gates. +- [ ] Publish only after release approval; creating/pushing a version tag triggers + the existing trusted-PyPI publishing workflow. + +## Local migration validation + +The migration is kept at 1.0.0rc1. Local results are recorded in MIGRATION.md. +Cross-platform CI and physical-device checks remain release gates even when the +local suite passes. This migration does not publish packages or push tags. diff --git a/src/ctmodbus/app.py b/src/ctmodbus/app.py index beaafe4..1830683 100644 --- a/src/ctmodbus/app.py +++ b/src/ctmodbus/app.py @@ -137,6 +137,10 @@ async def dispatch(self, text, **kwargs): # pylint: disable=too-many-branches raise CommandError("Connection is closing; retry when it finishes") self._device_dispatches.add(task) try: + 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) warnings = self._record_warnings.get(task) if warnings: diff --git a/tests/smoke_test.py b/tests/smoke_test.py new file mode 100644 index 0000000..616cd1d --- /dev/null +++ b/tests/smoke_test.py @@ -0,0 +1,29 @@ +"""Run against an installed wheel or sdist, without an editable source tree.""" + +import asyncio +import subprocess +import tempfile +from importlib.metadata import version + +from ctmodbus.app import ModbusApp + + +def main(): + assert version("ctmodbus").startswith("1.") + assert version("ctui").startswith("1.") + result = subprocess.run( + ["ctmodbus", "--help"], check=True, capture_output=True, text=True + ) + assert "read holding_registers" in result.stdout + assert "profile connect" in result.stdout + with tempfile.TemporaryDirectory() as directory: + app = ModbusApp(data_dir=directory) + status = asyncio.run( + app.run_cli(["-c", "project", "-c", "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 index 32ab4e5..cfb4f3b 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -385,6 +385,18 @@ async def test_cli_command_file_and_argument_failure(self): self.assertEqual(status, 2) self.assertFalse(self.client.connected) + async def test_external_cancellation_then_project_change(self): + 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): await self.app.configs.save( "bad", {"transport": "tcp", "target": "localhost", "timeout": "oops"} From ac4af8d0de71ba5f76b4e060292ffa87a56b53c2 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Sun, 13 Sep 2026 19:32:50 -0600 Subject: [PATCH 07/38] feat: add async Modbus TLS connections --- CHANGELOG.md | 3 + MIGRATION.md | 10 ++- README.md | 20 ++++- src/ctmodbus/app.py | 37 ++++++++ src/ctmodbus/connection.py | 50 +++++++++-- tests/test_tls.py | 176 +++++++++++++++++++++++++++++++++++++ 6 files changed, 286 insertions(+), 10 deletions(-) create mode 100644 tests/test_tls.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8813a5b..9102f09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 1.0.0rc1 — unreleased +- Add `connect tls` with verified server certificates, custom CA roots, optional + client identity, explicit insecure mode, and persisted TLS connection settings. + - Migrate from ctui 0.x to ctui 1.0.0rc1 and require Python 3.11+. - Use native async PyModbus 3.15 clients for TCP, UDP, RTU, and ASCII. - Replace global runtime state with an application-owned connection lifecycle. diff --git a/MIGRATION.md b/MIGRATION.md index 11a7a5b..9f0edc8 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -14,7 +14,7 @@ Commit milestones: Retain TCP/UDP/RTU/ASCII, discovery, identification, four read types, and coil/register writes. Repair existing multi-write and response-validation gaps. -Defer TLS client, polling, tags, simulation, proxying, raw/fuzzy requests, +Defer polling, tags, simulation, proxying, raw/fuzzy requests, tunneling, and historian integration. Release gates: command parity in TUI and CLI; nonblocking I/O; serialized @@ -44,3 +44,11 @@ with distribution smoke tests required before publication. Those remote jobs have not been run from this workspace. Physical serial hardware and real terminal usability checks remain on RELEASE_CHECKLIST.md. No release was published and no tags were pushed. + +## TLS follow-up + +Added native async `connect tls` after the initial migration, with server +verification, custom trust roots, optional client certificates, and profile +persistence. TLS integration uses ephemeral OpenSSL certificates and covers +verified reads/writes, mutual TLS, trust and hostname failures, and explicit +insecure connections. diff --git a/README.md b/README.md index ef0fe89..92a60a5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ An asynchronous Modbus tool for device testing, built with [ctui](https://github.com/ControlThings-io/ctui) and [PyModbus](https://github.com/pymodbus-dev/pymodbus). -The 1.0 release candidate supports TCP, UDP, serial RTU, and serial ASCII; +The 1.0 release candidate supports TCP, UDP, TLS, serial RTU, and serial ASCII; device identification; coils, discrete inputs, input registers, and holding registers; and single/multiple coil and holding-register writes. @@ -37,6 +37,7 @@ for the full list, including ctui's project, config, and history commands. connect # suggest serial ports and local services connect tcp 10.10.10.1 --port 502 --unit 1 connect udp 10.10.10.1 --port 10502 +connect tls plc.example.com --ca-file plant-ca.pem connect rtu /dev/ttyUSB0 --baudrate 9600 --parity E connect ascii COM2 --baudrate 9600 read id @@ -73,9 +74,18 @@ All connections accept `--unit` (1–247), `--timeout` (seconds), and `--retries (0–10). Defaults are unit 1, zero retries, and a timeout of 3 seconds for network connections or 1 second for serial connections. Serial defaults are 9600 baud, 8 data bits, no parity, and 1 stop bit; override these with `--baudrate`, -`--bytesize`, `--parity`, and `--stopbits`. Network ports default to 502; supply +`--bytesize`, `--parity`, and `--stopbits`. TCP/UDP ports default to 502 and TLS to 802; supply host and port separately, including for IPv6 addresses. +`connect tls HOST` uses Modbus TLS framing and verifies the server certificate +against system trust roots and the supplied hostname. Use `--ca-file PATH` for +a private CA and `--cert-file PATH --key-file PATH` for client authentication. +A certificate file may contain its private key; otherwise supply `--key-file`. +Keys must be unencrypted. Certificate loading runs off the event loop. +`--insecure` explicitly disables server certificate and hostname verification. +Profiles save these file paths and the verification setting, not certificate or +private-key contents; the files must remain available when reconnecting. + Device I/O is asynchronous, with requests serialized on the single connection. The status bar shows the project, transport state, and read progress. `cancel` or `close` cancels outstanding device work and closes the transport. Reconnect @@ -156,11 +166,13 @@ servers, and a terminal runtime with injected input/output. POSIX systems also exercise RTU/ASCII over bridged pseudo-terminals; Windows skips those PTY tests. For a local manual TCP fixture, run `uv run python tests/server.py tcp --port 5020`. The same fixture supports `udp`, `rtu`, and `ascii`; serial fixtures take -`--target DEVICE`. Physical serial adapters still need hardware smoke testing. +`--target DEVICE`. TLS integration tests use temporary certificates generated with `openssl` and +are skipped if that executable is unavailable. Physical serial adapters still +need hardware smoke testing. This is a breaking migration from 0.x. Legacy command spellings, space-separated multi-write values, host:port syntax, and old storage formats are not supported. -Polling, tags, TLS client support, simulation, proxies, raw/fuzzy requests, +Polling, tags, simulation, proxies, raw/fuzzy requests, tunneling, and historian integration remain deferred. See [CHANGELOG.md](CHANGELOG.md), [MIGRATION.md](MIGRATION.md), and diff --git a/src/ctmodbus/app.py b/src/ctmodbus/app.py index 1830683..175fa18 100644 --- a/src/ctmodbus/app.py +++ b/src/ctmodbus/app.py @@ -15,6 +15,13 @@ name: Argument(flags=(f"--{name}",)) for name in ("port", "unit", "timeout", "retries") } +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 ( @@ -122,6 +129,7 @@ async def dispatch(self, text, **kwargs): # pylint: disable=too-many-branches ) opening = item.name in { "connect tcp", + "connect tls", "connect udp", "connect rtu", "connect ascii", @@ -255,6 +263,35 @@ async def connect_tcp( ) ) + @command(name="connect tls", arguments=TLS_ARGUMENTS) + async def connect_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="connect udp", arguments=NETWORK_ARGUMENTS) async def connect_udp( self, diff --git a/src/ctmodbus/connection.py b/src/ctmodbus/connection.py index 589cf5c..2f3af9a 100644 --- a/src/ctmodbus/connection.py +++ b/src/ctmodbus/connection.py @@ -1,7 +1,9 @@ """Native async transports and serialized, cancellable connection ownership.""" import asyncio +import inspect import math +import ssl from contextlib import asynccontextmanager from dataclasses import asdict, dataclass from typing import Literal @@ -11,11 +13,12 @@ from pymodbus.client import ( AsyncModbusSerialClient, AsyncModbusTcpClient, + AsyncModbusTlsClient, AsyncModbusUdpClient, ) from pymodbus.exceptions import ModbusException -Transport = Literal["tcp", "udp", "rtu", "ascii"] +Transport = Literal["tcp", "udp", "tls", "rtu", "ascii"] @dataclass(frozen=True) @@ -32,10 +35,14 @@ class ConnectionSettings: 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): - if self.transport not in ("tcp", "udp", "rtu", "ascii"): - raise CommandError("Transport must be tcp, udp, rtu, or ascii") + 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 ( @@ -62,6 +69,19 @@ def __post_init__(self): 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) @@ -71,19 +91,37 @@ 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") + if self.transport in ("tcp", "udp", "tls") else self.target ) return f"{self.transport.upper()} {target} unit {self.unit}" -def create_client(settings): +def tls_context(settings): + """Load trust roots and optional client identity off the event loop.""" + 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): """Construct a client in the running event loop; disable implicit reconnects.""" 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": @@ -127,6 +165,8 @@ async def connect(self, settings): 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(): diff --git a/tests/test_tls.py b/tests/test_tls.py new file mode 100644 index 0000000..a5d7436 --- /dev/null +++ b/tests/test_tls.py @@ -0,0 +1,176 @@ +"""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): + asyncSetUp = test_commands.CommandTests.asyncSetUp + asyncTearDown = test_commands.CommandTests.asyncTearDown + + async def test_defaults_and_profile_roundtrip(self): + await self.app.dispatch("connect tls example.com") + self.assertEqual(self.app.connection.settings.port, 802) + self.assertFalse(self.app.connection.settings.insecure) + await self.app.dispatch("close") + await self.app.dispatch( + 'connect tls example.com --port 1802 --ca-file "root ca.pem" --cert-file client.pem --key-file client.key --insecure' + ) + await self.app.dispatch("profile save secure") + settings = self.app.connection.settings + await self.app.dispatch("close") + await self.app.dispatch("profile connect secure") + self.assertEqual(settings, self.app.connection.settings) + self.assertIn("TLS example.com:1802", self.app.connection_status()) + + async def test_invalid_key_option(self): + with self.assertRaisesRegex(CommandError, "requires cert-file"): + await self.app.dispatch("connect tls example.com --key-file client.key") + + async def test_tls_opening_guard(self): + entered, release = asyncio.Event(), asyncio.Event() + original = self.app.records.start_session + + async def delayed(*args, **kwargs): + 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("connect 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): + @classmethod + def setUpClass(cls): + 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): + cls.certificates.cleanup() + + async def asyncSetUp(self): + 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): + await self.app.on_stop() + await self.app.backend.close() + await self.server.shutdown() + self.directory.cleanup() + + async def test_verified_tls_operations(self): + await self.app.dispatch( + f'connect tls localhost --port {self.port} --ca-file "{self.cert}"' + ) + await test_integration.TransportTests.exercise(self, self.app) + + async def test_untrusted_certificate_rejected(self): + with self.assertRaises(CommandError): + await self.app.dispatch( + f"connect tls localhost --port {self.port} --timeout 0.5" + ) + self.assertIsNone(self.app.connection.client) + + async def test_hostname_mismatch_rejected(self): + with self.assertRaises(CommandError): + await self.app.dispatch( + f'connect 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): + await self.app.dispatch(f"connect 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): + with self.assertRaises(CommandError): + await self.app.dispatch( + f'connect 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. + 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'connect 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): + def test_secure_defaults(self): + 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): + for values in ({"insecure": "false"}, {"ca_file": 42}, {"key_file": "key"}): + with self.subTest(values=values), self.assertRaises(CommandError): + ConnectionSettings("tls", "localhost", **values) From 4fcc4f24ee1c37c3e7b3d45c72637715b31100e0 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Sun, 13 Sep 2026 19:39:16 -0600 Subject: [PATCH 08/38] docs: specify v1.0.0rc1 release target --- RELEASE_CHECKLIST.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 191123f..2b21856 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -1,5 +1,7 @@ # Release checklist +Target release tag: `v1.0.0rc1`. Python package version: `1.0.0rc1`. + ## Automated gates - [ ] `uv lock --check` From 426048a7ffb4223ac69e264bf70aa8462c71d0ae Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Sun, 13 Sep 2026 19:46:16 -0600 Subject: [PATCH 09/38] ci: align build test and release workflows with ctui --- .github/workflows/publish.yml | 152 ++++++++++++++++++++++++++++++++-- .github/workflows/test.yml | 114 +++++++++++++++++++------ CHANGELOG.md | 2 +- MIGRATION.md | 11 +++ RELEASE_CHECKLIST.md | 26 +++++- 5 files changed, 271 insertions(+), 34 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a87f0b7..fe78760 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,19 +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: - checks: - uses: ./.github/workflows/test.yml + 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: checks + needs: + - verify runs-on: ubuntu-latest permissions: contents: read @@ -30,14 +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 + - 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 index aa52e5e..5ee6b22 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,50 +1,110 @@ -name: Tests +name: Test on: - pull_request: push: - branches: ["**"] - workflow_call: + branches: + - "**" + pull_request: + workflow_dispatch: permissions: contents: read jobs: - test: + tests: + name: Python ${{ matrix.python-version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - python: ["3.11", "3.12", "3.13", "3.14"] - runs-on: ${{ matrix.os }} + os: + - ubuntu-latest + - ubuntu-24.04-arm + - windows-latest + - macos-latest + python-version: + - "3.11" + - "3.12" + - "3.13" + - "3.14" + steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 + + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: - python-version: ${{ matrix.python }} enable-cache: false - - run: uv sync --locked - - run: uv run python -m unittest discover -s tests -v - - run: uv run black --check src tests - - run: uv run isort --check-only src tests - - run: uv run pylint src/ctmodbus - distributions: + - 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: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 + + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: - python-version: "3.11" enable-cache: false - - run: uv build - - name: Test installed distributions - shell: bash - run: | - for artifact in dist/*.whl dist/*.tar.gz; do - uv run --isolated --no-project --with "$artifact" tests/smoke_test.py - done + + - 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/CHANGELOG.md b/CHANGELOG.md index 9102f09..785f636 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.0.0rc1 — unreleased +## [1.0.0rc1] - Unreleased - Add `connect tls` with verified server certificates, custom CA roots, optional client identity, explicit insecure mode, and persisted TLS connection settings. diff --git a/MIGRATION.md b/MIGRATION.md index 9f0edc8..8a55511 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -52,3 +52,14 @@ verification, custom trust roots, optional client certificates, and profile persistence. TLS integration uses ephemeral OpenSSL certificates and covers verified reads/writes, mutual TLS, trust and hostname failures, and explicit insecure connections. + +## Workflow alignment with ctui + +The test and publish workflows now follow ctui's workflow structure: Python +3.11–3.14 on Linux x86-64/ARM64, Windows, and macOS; dedicated quality checks; +post-test builds and installed-distribution checks; and artifact uploads. +Publication validates tag/package versions, extracts curated changelog notes, +attests and publishes the tested artifacts, and attaches them to GitHub releases. +Alpha, beta, and release-candidate tags are marked prerelease and never latest. +ctmodbus retains its pylint check; formatting targets omit ctui's examples +folder, and release notes/artifact labels use the ctmodbus name. diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 2b21856..d395815 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -9,7 +9,7 @@ Target release tag: `v1.0.0rc1`. Python package version: `1.0.0rc1`. - [ ] `uv run isort --check-only src tests` - [ ] `uv run pylint src/ctmodbus` - [ ] `uv run python -m unittest discover -s tests -v` -- [ ] CI green on Linux, macOS, and Windows with Python 3.11–3.14. +- [ ] 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 --isolated --no-project --with dist/ARTIFACT tests/smoke_test.py`. @@ -31,3 +31,27 @@ Target release tag: `v1.0.0rc1`. Python package version: `1.0.0rc1`. The migration is kept at 1.0.0rc1. Local results are recorded in MIGRATION.md. Cross-platform CI and physical-device checks remain release gates even when the local suite passes. This migration does not publish packages or push tags. + +## GitHub and PyPI release setup + +- [ ] Confirm the `pypi` GitHub environment and PyPI trusted publisher reference + this repository and `.github/workflows/publish.yml`. +- [ ] Confirm the changelog has a `## [1.0.0rc1]` section and set its release date. + Release notes select the exact version first, falling back to the base version + for prereleases. Missing 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 matching annotated tag: + + ```bash + git tag -a v1.0.0rc1 -m "ctmodbus 1.0.0rc1" + git push origin v1.0.0rc1 + ``` + +- [ ] 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.0rc1` must be a prerelease, not latest. +- [ ] Confirm both distributions and attestations appear on PyPI, then install + `ctmodbus==1.0.0rc1` in a new environment and check help and a device workflow. +- [ ] Never reuse an already published version; fix and increment it instead. From d0e45e5cb4ba523169237d32f0f1e337a928e74b Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Fri, 18 Sep 2026 10:11:20 -0600 Subject: [PATCH 10/38] docs: add project instructions, status, and decision history --- AGENTS.md | 93 ++++++++++++++++++++++++++ docs/DECISIONS.md | 161 ++++++++++++++++++++++++++++++++++++++++++++++ docs/STATUS.md | 107 ++++++++++++++++++++++++++++++ 3 files changed, 361 insertions(+) create mode 100644 AGENTS.md create mode 100644 docs/DECISIONS.md create mode 100644 docs/STATUS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ab282b9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,93 @@ +# Repository instructions + +## Project continuity + +- Before working, read [docs/STATUS.md](docs/STATUS.md) and + [docs/DECISIONS.md](docs/DECISIONS.md), then inspect the current branch, + working tree, and recent commits. Reconcile stale notes with the code and + the user's latest instructions. +- After meaningful work, update status with completed changes, checks actually + run, remaining tasks, blockers, and concrete next steps. Include memory + updates with the corresponding code changes. +- Record significant accepted decisions and rationale in the decision log. + Label proposals, inferred rationale, and superseded decisions explicitly; + do not turn assistant suggestions into user commitments. +- Keep status concise and use Git history for detailed changes. Link existing + documentation rather than duplicating it. Use repository-relative paths; + do not commit private transcripts, credentials, or machine-specific logs. +- Date validation evidence and identify its revision. Never infer publication + or remote CI success from local tests or historical session summaries. +- Before a laptop handoff, record unfinished work and remaining validation. + Code and notes must be committed and pushed to transfer through Git; pull + the same branch on the receiving laptop before starting a new session. + These files carry context, not automatic chat-history synchronization. + +## Development preferences + +- Use Conventional Commits: a suitable prefix such as `feat:`, `fix:`, + `docs:`, `build:`, `test:`, or `ci:` and a concise description. +- Follow applicable official Python standards and PEPs, including packaging + and versioning. Prefer official Python and PyPA documentation as references. +- Follow GitHub-recommended and sound development practices proportionately: + clear commits and reviews, appropriate tests and CI, maintainable code, + secure defaults, managed dependencies, and documented release procedures. +- Keep Python code and examples simple and approachable. Prefer typed public + framework APIs and shared implementations over duplicated command paths. +- Use uv and the checked-in lockfile. Python 3.11 is the baseline; CI defines + Python 3.11–3.14 on Linux x86-64/ARM64, Windows, and macOS. +- Update README examples when public behavior changes. The deliberate 0.x + compatibility break is migration scope, not blanket permission for future + undocumented breaking changes. + +## 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. + +## 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 +``` + +Run checks appropriate to the change. Documentation-only work ordinarily needs +content, link, and whitespace review. Runtime changes need relevant regression +tests; 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, connection commands, profiles, project guards, + records, progress, cancellation, and shutdown. +- `src/ctmodbus/connection.py`: settings, client factory, serialization, timeouts. +- `src/ctmodbus/operations.py`: typed reads/writes, chunking, response validation. +- `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. +- `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 scope, user-facing changes, and release gates. + +Preferences were adapted from the owner's +[ctui instructions](https://github.com/ControlThings-io/ctui/blob/main/AGENTS.md) +and its status/decision files on 2026-09-18, plus direct ctmodbus instructions. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md new file mode 100644 index 0000000..e30d4f9 --- /dev/null +++ b/docs/DECISIONS.md @@ -0,0 +1,161 @@ +# Project decisions + +Recorded 2026-09-18 from the evidence described in [STATUS.md](STATUS.md). +Dates below refer to the original discussion/implementation. “Accepted” means +explicit owner direction or the migration plan they authorized; “Implemented +policy” records the chosen behavior without claiming the owner specified every +detail. New ideas remain proposals until accepted. + +## 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 data names, comma-separated write 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. + +## 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. + +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. + +## D09 — Durable development preferences and commit history + +Accepted, 2026-09-13; reaffirmed by ctui memory reviewed 2026-09-18. + +The owner requested Conventional Commits for all projects and official Python/ +packaging standards plus recommended GitHub and general development practices. +Keep changes reviewable, tests proportionate, dependencies managed, and releases +documented. Transferable ctui preferences also favor simple approachable Python, +shared typed command paths, and truthful evidence-based AI handoffs. + +The owner authorized rewording this branch's migration commits. Current hashes +in STATUS are authoritative; `backup/pre-conventional-commits-504ffa6` preserves +the earlier seven commits. Do not count that backup as unfinished implementation +or infer standing authorization to rewrite future shared history. + +## D10 — Git-tracked cross-laptop memory + +Accepted, 2026-09-18. + +Maintain AGENTS.md for stable instructions, STATUS.md for current work and next +steps, and DECISIONS.md for durable rationale. Update them alongside meaningful +work and transfer them with the branch. Record source limitations, unanswered +questions, and historical validation dates. Keep private transcripts out of Git. +Project-neutral preferences were extracted from all three ctui memory files; +ctui-specific features and release plans are not automatically ctmodbus tasks. diff --git a/docs/STATUS.md b/docs/STATUS.md new file mode 100644 index 0000000..25a9a3a --- /dev/null +++ b/docs/STATUS.md @@ -0,0 +1,107 @@ +# Project status + +Last reconciled: 2026-09-18, `dev-v1.0.0` at `426048a`. + +## Evidence and scope + +Reviewed the locally available ctmodbus conversation of September 13 (migration, +TLS, commit conventions, release target, and CI alignment), its overlapping +review log, this September 18 request, and Git history since August 22, 2026. +There are nine commits on the active branch in that period, all September 13; +seven additional commits on a backup ref preserve the pre-rewording history, +not seven additional milestones. Earlier baseline: `f8f91d9` on August 20. +The owner confirmed the August 22, 2026 cutoff and that no other plans or +decisions need preserving. + +Also read the owner's GitHub ctui [instructions](https://github.com/ControlThings-io/ctui/blob/main/AGENTS.md), +[status](https://github.com/ControlThings-io/ctui/blob/main/docs/STATUS.md), and +[decisions](https://github.com/ControlThings-io/ctui/blob/main/docs/DECISIONS.md) +on September 18 for transferable preferences. Other-laptop/product chats absent +from local transcripts were unavailable; this is not a claim to have reviewed +all account-wide conversations. Private logs are not required to use these notes. + +## Current state + +- All six migration milestones are implemented, followed by TLS and CI alignment. +- Package version is `1.0.0rc1`; dependencies include `ctui==1.0.0rc1` and + `pymodbus[serial]>=3.15.0,<3.16`, locked to 3.15.0. Python requires >=3.11,<4. +- Active branch and local `origin/dev-v1.0.0` both point to `426048a`; local + `main`/`origin/main` remain at `f8f91d9`. Remote refs were not refreshed. +- The working tree was clean before adding these three memory files. +- No local `v1.0.0rc1` tag exists; the changelog still says Unreleased. + The owner confirmed on September 18 that RC1 has not been published and + feature work is still ongoing. No subsequent manual validation was reported. + Remote PR/merge state and CI outcomes have not been verified in this review. +- ctui's own September 18 notes report its RC1 published and RC2 planned. + That is dependency context, not evidence that ctmodbus was released or a + decision to move ctmodbus to RC2. + +## Completed milestones + +All entries below were implemented September 13, 2026. + +| Commit | Completed work | +| --- | --- | +| `c225522` | ctui 1.0 RC application foundation; Python 3.11 baseline; modern PyModbus; unified pyproject packaging; removed legacy setup/version files. | +| `39d0633` | Native async TCP/UDP/RTU/ASCII, discovery, one serialized connection, lifecycle cleanup. | +| `b5dabf8` | Typed range reads and multi-value writes, validation, acknowledgement checks, partial results, safe formatting. | +| `c6054e2` | Named profiles, project records/history, progress, cancellation, and project-switch guards. | +| `500949f` | Command/lifecycle regression tests, actual TCP/UDP and PTY serial tests, injected-terminal tests, modern server fixture. | +| `7914437` | Migration docs, README, changelog, release checklist, CI and isolated installed-package smoke tests. | +| `ac4af8d` | Async Modbus TLS, server verification, private CA/client certificates, explicit insecure mode, persisted settings, TLS integration tests. | +| `4fcc4f2` | Explicit release tag `v1.0.0rc1`. | +| `426048a` | ctui-aligned 16-combination CI, quality/build/artifact gates, version validation, attestations, curated notes and prerelease handling. | + +## Next concrete steps + +The owner is still developing features; release preparation is a later phase. +The remaining feature list has not yet been supplied. Use +[RELEASE_CHECKLIST.md](../RELEASE_CHECKLIST.md) as the +authoritative release procedure rather than marking historical checks complete. + +1. Capture the owner's remaining feature work as concrete tasks, implement it, + and record relevant validation. Reconcile remote changes with this branch. +2. On the intended release revision, rerun source/lockfile checks and isolated + wheel/sdist smoke tests; inspect metadata, license, README, and dependencies. +3. Verify remote CI across all 16 OS/Python combinations and record result links. +4. Exercise a real terminal: completion, scrolling, progress/errors, slow-I/O + cancellation, and exit cleanup. Test RTU/ASCII on physical adapters/devices; + record OS, Python, adapter/device, revision, and outcomes. +5. Confirm trusted-PyPI publisher/environment setup, changelog date, and release + approval before tagging. Verify artifacts, attestations, GitHub prerelease + classification, and fresh installation after publication. Never reuse a + published version. +6. Before stable 1.0, adopt the tested stable ctui 1.0 release, update package + version/lockfile/changelog, and repeat the release gates. + +No implementation blocker is established. Release readiness lacks verified +remote results and recorded manual hardware/terminal checks. + +## Deferred scope + +Polling, tags, device cloning/simulation, proxies, raw/fuzzy requests, tunneling, +and historian integration remain deferred. TLS was initially outside the +migration and then explicitly requested and completed; do not list it as pending. +ctui's optional job manager and typing-marker ideas are not ctmodbus commitments. + +## Validation record + +- September 13 initial migration: 34 tests passed on Python 3.11 and 3.14; + formatting, imports, pylint, lock validation, builds, and isolated wheel/sdist + smoke checks passed. See [MIGRATION.md](../MIGRATION.md). +- September 13 TLS/workflow follow-up through `426048a`: the local session + reports 45 tests passing, quality checks, workflow syntax/matrix/tag/changelog/ + prerelease-script checks, and both distribution smoke tests. This supersedes + the initial 34-test count, not the outstanding hardware/remote gates. +- September 18 memory task: documentation only; content reconciled against + transcripts, Git history, code, tests, workflows, and ctui memory files. + Runtime tests were not rerun. Local Markdown links and whitespace were checked. + +## Open questions and laptop handoff + +- Which features are still in progress or planned, and which should be next? + The owner confirmed feature work is ongoing but has not listed the tasks. + +Before switching laptops, update these notes and commit/push the working branch; +pull that branch on the receiving laptop and read root AGENTS.md first. +Uncommitted/unpushed files and local chat history do not transfer through Git. From 4e340983cee79d54b42d7b461bfe9b57a4f1df8b Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Sun, 20 Sep 2026 14:03:18 -0600 Subject: [PATCH 11/38] docs: separate global preferences from project guidance --- AGENTS.md | 54 ++++-------------- docs/DECISIONS.md | 46 ++++++++-------- docs/STATUS.md | 138 +++++++++++++++------------------------------- 3 files changed, 75 insertions(+), 163 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ab282b9..d3bf11c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,43 +1,15 @@ -# Repository instructions +# ctmodbus instructions -## Project continuity +## Project context -- Before working, read [docs/STATUS.md](docs/STATUS.md) and - [docs/DECISIONS.md](docs/DECISIONS.md), then inspect the current branch, - working tree, and recent commits. Reconcile stale notes with the code and - the user's latest instructions. -- After meaningful work, update status with completed changes, checks actually - run, remaining tasks, blockers, and concrete next steps. Include memory - updates with the corresponding code changes. -- Record significant accepted decisions and rationale in the decision log. - Label proposals, inferred rationale, and superseded decisions explicitly; - do not turn assistant suggestions into user commitments. -- Keep status concise and use Git history for detailed changes. Link existing - documentation rather than duplicating it. Use repository-relative paths; - do not commit private transcripts, credentials, or machine-specific logs. -- Date validation evidence and identify its revision. Never infer publication - or remote CI success from local tests or historical session summaries. -- Before a laptop handoff, record unfinished work and remaining validation. - Code and notes must be committed and pushed to transfer through Git; pull - the same branch on the receiving laptop before starting a new session. - These files carry context, not automatic chat-history synchronization. +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; review D01–D08 together for cross-cutting architecture changes. Update +these files alongside meaningful changes using the global continuity guidance. -## Development preferences - -- Use Conventional Commits: a suitable prefix such as `feat:`, `fix:`, - `docs:`, `build:`, `test:`, or `ci:` and a concise description. -- Follow applicable official Python standards and PEPs, including packaging - and versioning. Prefer official Python and PyPA documentation as references. -- Follow GitHub-recommended and sound development practices proportionately: - clear commits and reviews, appropriate tests and CI, maintainable code, - secure defaults, managed dependencies, and documented release procedures. -- Keep Python code and examples simple and approachable. Prefer typed public - framework APIs and shared implementations over duplicated command paths. -- Use uv and the checked-in lockfile. Python 3.11 is the baseline; CI defines - Python 3.11–3.14 on Linux x86-64/ARM64, Windows, and macOS. -- Update README examples when public behavior changes. The deliberate 0.x - compatibility break is migration scope, not blanket permission for future - undocumented breaking changes. +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). ## Project constraints @@ -66,9 +38,7 @@ git diff --check uv build ``` -Run checks appropriate to the change. Documentation-only work ordinarily needs -content, link, and whitespace review. Runtime changes need relevant regression -tests; packaging/release changes also need isolated wheel/sdist smoke tests via +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. @@ -87,7 +57,3 @@ Publishing requires release approval; pushing a version tag triggers publication - `.github/workflows/`: platform tests and tag-triggered publishing. - [MIGRATION.md](MIGRATION.md), [CHANGELOG.md](CHANGELOG.md), and the release checklist describe migration scope, user-facing changes, and release gates. - -Preferences were adapted from the owner's -[ctui instructions](https://github.com/ControlThings-io/ctui/blob/main/AGENTS.md) -and its status/decision files on 2026-09-18, plus direct ctmodbus instructions. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index e30d4f9..2e7383f 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -1,10 +1,10 @@ # Project decisions -Recorded 2026-09-18 from the evidence described in [STATUS.md](STATUS.md). -Dates below refer to the original discussion/implementation. “Accepted” means -explicit owner direction or the migration plan they authorized; “Implemented -policy” records the chosen behavior without claiming the owner specified every -detail. New ideas remain proposals until accepted. +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 @@ -134,28 +134,26 @@ 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. -## D09 — Durable development preferences and commit history +## D09 — Migration commit history -Accepted, 2026-09-13; reaffirmed by ctui memory reviewed 2026-09-18. +Accepted, 2026-09-13. -The owner requested Conventional Commits for all projects and official Python/ -packaging standards plus recommended GitHub and general development practices. -Keep changes reviewable, tests proportionate, dependencies managed, and releases -documented. Transferable ctui preferences also favor simple approachable Python, -shared typed command paths, and truthful evidence-based AI handoffs. +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. -The owner authorized rewording this branch's migration commits. Current hashes -in STATUS are authoritative; `backup/pre-conventional-commits-504ffa6` preserves -the earlier seven commits. Do not count that backup as unfinished implementation -or infer standing authorization to rewrite future shared history. +## D10 — Project memory and instruction scope -## D10 — Git-tracked cross-laptop memory +Accepted, 2026-09-18; scope refined by owner request, 2026-09-20. -Accepted, 2026-09-18. +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. -Maintain AGENTS.md for stable instructions, STATUS.md for current work and next -steps, and DECISIONS.md for durable rationale. Update them alongside meaningful -work and transfer them with the branch. Record source limitations, unanswered -questions, and historical validation dates. Keep private transcripts out of Git. -Project-neutral preferences were extracted from all three ctui memory files; -ctui-specific features and release plans are not automatically ctmodbus tasks. +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. diff --git a/docs/STATUS.md b/docs/STATUS.md index 25a9a3a..c065232 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,78 +1,36 @@ # Project status -Last reconciled: 2026-09-18, `dev-v1.0.0` at `426048a`. - -## Evidence and scope - -Reviewed the locally available ctmodbus conversation of September 13 (migration, -TLS, commit conventions, release target, and CI alignment), its overlapping -review log, this September 18 request, and Git history since August 22, 2026. -There are nine commits on the active branch in that period, all September 13; -seven additional commits on a backup ref preserve the pre-rewording history, -not seven additional milestones. Earlier baseline: `f8f91d9` on August 20. -The owner confirmed the August 22, 2026 cutoff and that no other plans or -decisions need preserving. - -Also read the owner's GitHub ctui [instructions](https://github.com/ControlThings-io/ctui/blob/main/AGENTS.md), -[status](https://github.com/ControlThings-io/ctui/blob/main/docs/STATUS.md), and -[decisions](https://github.com/ControlThings-io/ctui/blob/main/docs/DECISIONS.md) -on September 18 for transferable preferences. Other-laptop/product chats absent -from local transcripts were unavailable; this is not a claim to have reviewed -all account-wide conversations. Private logs are not required to use these notes. +Last reconciled: 2026-09-20, `dev-v1.0.0` at `d0e45e5`, plus the uncommitted +documentation changes described below. Working tree was clean at task start. ## Current state -- All six migration milestones are implemented, followed by TLS and CI alignment. -- Package version is `1.0.0rc1`; dependencies include `ctui==1.0.0rc1` and - `pymodbus[serial]>=3.15.0,<3.16`, locked to 3.15.0. Python requires >=3.11,<4. -- Active branch and local `origin/dev-v1.0.0` both point to `426048a`; local - `main`/`origin/main` remain at `f8f91d9`. Remote refs were not refreshed. -- The working tree was clean before adding these three memory files. -- No local `v1.0.0rc1` tag exists; the changelog still says Unreleased. - The owner confirmed on September 18 that RC1 has not been published and - feature work is still ongoing. No subsequent manual validation was reported. - Remote PR/merge state and CI outcomes have not been verified in this review. -- ctui's own September 18 notes report its RC1 published and RC2 planned. - That is dependency context, not evidence that ctmodbus was released or a - decision to move ctmodbus to RC2. - -## Completed milestones - -All entries below were implemented September 13, 2026. - -| Commit | Completed work | -| --- | --- | -| `c225522` | ctui 1.0 RC application foundation; Python 3.11 baseline; modern PyModbus; unified pyproject packaging; removed legacy setup/version files. | -| `39d0633` | Native async TCP/UDP/RTU/ASCII, discovery, one serialized connection, lifecycle cleanup. | -| `b5dabf8` | Typed range reads and multi-value writes, validation, acknowledgement checks, partial results, safe formatting. | -| `c6054e2` | Named profiles, project records/history, progress, cancellation, and project-switch guards. | -| `500949f` | Command/lifecycle regression tests, actual TCP/UDP and PTY serial tests, injected-terminal tests, modern server fixture. | -| `7914437` | Migration docs, README, changelog, release checklist, CI and isolated installed-package smoke tests. | -| `ac4af8d` | Async Modbus TLS, server verification, private CA/client certificates, explicit insecure mode, persisted settings, TLS integration tests. | -| `4fcc4f2` | Explicit release tag `v1.0.0rc1`. | -| `426048a` | ctui-aligned 16-combination CI, quality/build/artifact gates, version validation, attestations, curated notes and prerelease handling. | - -## Next concrete steps - -The owner is still developing features; release preparation is a later phase. -The remaining feature list has not yet been supplied. Use -[RELEASE_CHECKLIST.md](../RELEASE_CHECKLIST.md) as the -authoritative release procedure rather than marking historical checks complete. - -1. Capture the owner's remaining feature work as concrete tasks, implement it, - and record relevant validation. Reconcile remote changes with this branch. -2. On the intended release revision, rerun source/lockfile checks and isolated - wheel/sdist smoke tests; inspect metadata, license, README, and dependencies. -3. Verify remote CI across all 16 OS/Python combinations and record result links. -4. Exercise a real terminal: completion, scrolling, progress/errors, slow-I/O - cancellation, and exit cleanup. Test RTU/ASCII on physical adapters/devices; - record OS, Python, adapter/device, revision, and outcomes. -5. Confirm trusted-PyPI publisher/environment setup, changelog date, and release - approval before tagging. Verify artifacts, attestations, GitHub prerelease - classification, and fresh installation after publication. Never reuse a - published version. -6. Before stable 1.0, adopt the tested stable ctui 1.0 release, update package - version/lockfile/changelog, and repeat the release gates. +- The six migration milestones, TLS, and CI alignment are implemented through + `426048a`; `d0e45e5` added project instructions and continuity notes. +- Package version is `1.0.0rc1`; `ctui==1.0.0rc1` and + `pymodbus[serial]>=3.15.0,<3.16` (locked to 3.15.0); Python >=3.11,<4. +- The owner confirmed on September 18 that RC1 was unpublished and feature work + was ongoing. The remaining feature list has not been supplied. No local + `v1.0.0rc1` tag exists; changelog remains Unreleased. Remote refs, PRs, + publication state, and CI results were not refreshed for this review. +- September 20 documentation changes separate personal/global instructions from + ctmodbus guidance, shorten this status, and retain task-relevant decisions. + Global instructions are machine-local and must be synchronized separately. + +## Next steps and validation gaps + +1. Obtain the owner's next feature task; deferred ideas below are not commitments. + Reconcile remote changes before integration or release work. +2. After feature work, follow [RELEASE_CHECKLIST.md](../RELEASE_CHECKLIST.md) on + the intended release revision: source/lockfile checks, wheel/sdist smoke + tests, metadata review, and verified remote CI across all 16 combinations. +3. Record real-terminal checks (completion, scrolling, progress/errors, + cancellation, exit cleanup) and RTU/ASCII checks on physical adapters/devices, + including revision, OS, Python, hardware, and outcomes. PTYs do not replace + physical checks. No subsequent manual validation has been reported. +4. Obtain release approval and complete publisher/changelog prerequisites before + tagging; verify publication using the release checklist. Before stable 1.0, + adopt tested stable ctui 1.0 and repeat release gates (D08). No implementation blocker is established. Release readiness lacks verified remote results and recorded manual hardware/terminal checks. @@ -80,28 +38,18 @@ remote results and recorded manual hardware/terminal checks. ## Deferred scope Polling, tags, device cloning/simulation, proxies, raw/fuzzy requests, tunneling, -and historian integration remain deferred. TLS was initially outside the -migration and then explicitly requested and completed; do not list it as pending. -ctui's optional job manager and typing-marker ideas are not ctmodbus commitments. - -## Validation record - -- September 13 initial migration: 34 tests passed on Python 3.11 and 3.14; - formatting, imports, pylint, lock validation, builds, and isolated wheel/sdist - smoke checks passed. See [MIGRATION.md](../MIGRATION.md). -- September 13 TLS/workflow follow-up through `426048a`: the local session - reports 45 tests passing, quality checks, workflow syntax/matrix/tag/changelog/ - prerelease-script checks, and both distribution smoke tests. This supersedes - the initial 34-test count, not the outstanding hardware/remote gates. -- September 18 memory task: documentation only; content reconciled against - transcripts, Git history, code, tests, workflows, and ctui memory files. - Runtime tests were not rerun. Local Markdown links and whitespace were checked. - -## Open questions and laptop handoff - -- Which features are still in progress or planned, and which should be next? - The owner confirmed feature work is ongoing but has not listed the tasks. - -Before switching laptops, update these notes and commit/push the working branch; -pull that branch on the receiving laptop and read root AGENTS.md first. -Uncommitted/unpushed files and local chat history do not transfer through Git. +and historian integration remain deferred. TLS is complete. ctui's optional job +manager, typing-marker ideas, and release plans are not ctmodbus commitments. + +## Validation evidence + +- Historical, September 13 through `426048a`: the prior local session reported + 45 passing tests, quality/lock/build checks, workflow checks, and isolated + wheel/sdist smoke tests. This supersedes the initial 34-test migration result + in [MIGRATION.md](../MIGRATION.md); it does not establish remote CI or hardware + success. Original evidence review is preserved in `d0e45e5`. +- September 18 at `d0e45e5`: documentation content, local links, and whitespace + reviewed; runtime tests not rerun. +- September 20 documentation edits on `d0e45e5`: content and local Markdown + links reviewed; `git diff --check` passed. Runtime tests not run because no + runtime or packaging behavior changed. From 5eb04ee6ae3ae97f379099a1c92726e96d8f933d Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Sun, 20 Sep 2026 23:54:29 -0600 Subject: [PATCH 12/38] feat: add project-scoped typed Modbus tags --- AGENTS.md | 1 + CHANGELOG.md | 3 + README.md | 67 ++++- docs/DECISIONS.md | 34 +++ docs/STATUS.md | 25 +- src/ctmodbus/app.py | 61 +++- src/ctmodbus/operations.py | 9 +- src/ctmodbus/tags.py | 579 +++++++++++++++++++++++++++++++++++++ tests/test_tags.py | 223 ++++++++++++++ 9 files changed, 986 insertions(+), 16 deletions(-) create mode 100644 src/ctmodbus/tags.py create mode 100644 tests/test_tags.py diff --git a/AGENTS.md b/AGENTS.md index d3bf11c..32e2a11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,7 @@ Publishing requires release approval; pushing a version tag triggers publication records, progress, cancellation, and shutdown. - `src/ctmodbus/connection.py`: settings, client factory, serialization, timeouts. - `src/ctmodbus/operations.py`: typed reads/writes, chunking, response validation. +- `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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 785f636..040d075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [1.0.0rc1] - Unreleased +- Add project-scoped typed tags with Boolean, signed/unsigned integer, and + floating-point decoding; configurable byte/word order; tagged reads/writes; + and atomic TOML import/export. - Add `connect tls` with verified server certificates, custom CA roots, optional client identity, explicit insecure mode, and persisted TLS connection settings. diff --git a/README.md b/README.md index 92a60a5..731fb00 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ An asynchronous Modbus tool for device testing, built with The 1.0 release candidate supports TCP, UDP, TLS, serial RTU, and serial ASCII; device identification; coils, discrete inputs, input registers, and holding registers; and single/multiple coil and holding-register writes. +Project-scoped tags add names and integer, floating-point, or Boolean types to +those wire addresses. ## Installation @@ -70,6 +72,69 @@ Success means the device returned a matching acknowledgement, not an independent readback. Failed or interrupted writes may have reached the device; an unconfirmed outcome is reported explicitly. +## Tags + +Tags give device addresses project-scoped names and types. The type determines +the number of addresses, so creation takes only the first zero-based address. +Tags use the active connection and unit; they do not store a host, unit, or +connection profile. + +```text +tag create pause_sw coil 0 bool +tag create state holding_register 0 uint8 +tag create counter holding_register 1 uint16 +tag create timer holding_register 2 int32 +tag create energy input_register 10 float64 --word-order big +tag list +tag 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 +tag rename timer duration +tag delete duration +export tags my_tags +import tags my_tags.toml +``` + +Types are `bool`, signed and unsigned 8-, 16-, 32-, and 64-bit integers, and +32- or 64-bit floating point. Integer input accepts signed decimal values and +`0b`, `0o`, `0d`, or `0x` radix prefixes; underscores are allowed. Boolean input +accepts `0`/`1`, `false`/`true`, and `off`/`on`. Floating input accepts finite +decimal or scientific notation; NaN and infinity are rejected. + +For signed integer tags, unsigned binary, octal, or hexadecimal literals are +interpreted as fixed-width bit patterns: `0x8000_0000` is `-2147483648` for an +`int32`, and `0xFFFF_FFFF` is `-1`. Decimal input remains numeric and must fit +the declared range. Place ctui's `--` end-of-options marker before a negative +positional value, as in `write tag timer -- -2000000000`. + +Register tags default to Modbus-standard big-endian byte order inside each +16-bit register and little-endian word order across registers. Override these +with `--byte-order little|big` and `--word-order little|big`. An 8-bit value +occupies the selected byte and the unused byte is written as zero: big byte +order places it in the low byte; little byte order places it in the high byte. +Byte and word order do not apply to Boolean tags. + +`tag create` uses the singular table names `coil`, `discrete_input`, +`input_register`, and `holding_register`. Coils and discrete inputs support +`bool`; input and holding registers support numeric types. Writes are limited to +coils and holding registers. Overlapping tags are allowed and reported when +created because alternate interpretations can be useful. Tagged operations +retain normal response validation, recording, cancellation, and uncertain-write +reporting. + +`read tags` without names reads every tag in name order. Supply a comma-separated +list to read only those tags while preserving the requested order and duplicates. + +Tag files are versioned TOML. Export appends `.toml` when absent and replaces +the destination atomically. Import validates the whole file before changing the +project. Existing names trigger a TUI confirmation; CLI and command-file use +report the conflicting names and require `--replace`. + All connections accept `--unit` (1–247), `--timeout` (seconds), and `--retries` (0–10). Defaults are unit 1, zero retries, and a timeout of 3 seconds for network connections or 1 second for serial connections. Serial defaults are 9600 baud, @@ -172,7 +237,7 @@ need hardware smoke testing. This is a breaking migration from 0.x. Legacy command spellings, space-separated multi-write values, host:port syntax, and old storage formats are not supported. -Polling, tags, simulation, proxies, raw/fuzzy requests, +Polling, simulation, proxies, raw/fuzzy requests, tunneling, and historian integration remain deferred. See [CHANGELOG.md](CHANGELOG.md), [MIGRATION.md](MIGRATION.md), and diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 2e7383f..accd643 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -157,3 +157,37 @@ 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. + +## 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 `tag 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`. + +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. diff --git a/docs/STATUS.md b/docs/STATUS.md index c065232..e303678 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,7 +1,7 @@ # Project status -Last reconciled: 2026-09-20, `dev-v1.0.0` at `d0e45e5`, plus the uncommitted -documentation changes described below. Working tree was clean at task start. +Last reconciled: 2026-09-20, `dev-v1.0.0` at `4e34098`, plus the uncommitted tag +feature described below. Working tree was clean at task start. ## Current state @@ -9,18 +9,18 @@ documentation changes described below. Working tree was clean at task start. `426048a`; `d0e45e5` added project instructions and continuity notes. - Package version is `1.0.0rc1`; `ctui==1.0.0rc1` and `pymodbus[serial]>=3.15.0,<3.16` (locked to 3.15.0); Python >=3.11,<4. -- The owner confirmed on September 18 that RC1 was unpublished and feature work - was ongoing. The remaining feature list has not been supplied. No local +- Project-scoped typed tags are implemented with persistence, tagged reads and + writes, endian-aware integer/float codecs, and atomic TOML exchange (D11). +- The owner confirmed on September 18 that RC1 was unpublished. No local `v1.0.0rc1` tag exists; changelog remains Unreleased. Remote refs, PRs, publication state, and CI results were not refreshed for this review. -- September 20 documentation changes separate personal/global instructions from - ctmodbus guidance, shorten this status, and retain task-relevant decisions. - Global instructions are machine-local and must be synchronized separately. +- The global/project instruction split is committed at `4e34098`. Global + instructions are machine-local and must be synchronized separately. ## Next steps and validation gaps -1. Obtain the owner's next feature task; deferred ideas below are not commitments. - Reconcile remote changes before integration or release work. +1. Reconcile remote changes before integration or release work. Exercise tag + completion, collision confirmation, and typed output in a real terminal. 2. After feature work, follow [RELEASE_CHECKLIST.md](../RELEASE_CHECKLIST.md) on the intended release revision: source/lockfile checks, wheel/sdist smoke tests, metadata review, and verified remote CI across all 16 combinations. @@ -37,7 +37,7 @@ remote results and recorded manual hardware/terminal checks. ## Deferred scope -Polling, tags, device cloning/simulation, proxies, raw/fuzzy requests, tunneling, +Polling, device cloning/simulation, proxies, raw/fuzzy requests, tunneling, and historian integration remain deferred. TLS is complete. ctui's optional job manager, typing-marker ideas, and release plans are not ctmodbus commitments. @@ -53,3 +53,8 @@ manager, typing-marker ideas, and release plans are not ctmodbus commitments. - September 20 documentation edits on `d0e45e5`: content and local Markdown links reviewed; `git diff --check` passed. Runtime tests not run because no runtime or packaging behavior changed. +- September 20 tag implementation on `4e34098`: 53 tests passed on Python 3.11, + including TCP/UDP, PTY serial, TLS, tag codecs/storage/I/O/import/export, and + project isolation. Black, isort, pylint, lock, and whitespace checks passed; + wheel and sdist builds and isolated installed-artifact smoke tests passed. + Remote CI and manual terminal/hardware checks remain outstanding. diff --git a/src/ctmodbus/app.py b/src/ctmodbus/app.py index 175fa18..06695af 100644 --- a/src/ctmodbus/app.py +++ b/src/ctmodbus/app.py @@ -1,15 +1,25 @@ """Application ownership and ctui lifecycle integration.""" import asyncio +import shlex from dataclasses import replace from importlib.metadata import version +from pathlib import Path from typing import Literal -from ctui import Argument, CommandError, CommandResult, CtuiApp, command +from ctui import ( + Argument, + CommandError, + CommandResult, + ConfirmationRequired, + CtuiApp, + command, +) from ctmodbus.connection import Connection, ConnectionSettings, create_client from ctmodbus.discovery import complete_serial, suggestions from ctmodbus.operations import ModbusCommandMixin +from ctmodbus.tags import TagCommandMixin, TagStore, parse_tag_document NETWORK_ARGUMENTS = { name: Argument(flags=(f"--{name}",)) @@ -42,7 +52,7 @@ async def complete_profiles(context): return list(await context.app.configs.list()) -class ModbusApp(ModbusCommandMixin, CtuiApp): +class ModbusApp(TagCommandMixin, ModbusCommandMixin, CtuiApp): """A Modbus client with one connection and project-scoped services.""" name = "ctmodbus" @@ -50,10 +60,13 @@ class ModbusApp(ModbusCommandMixin, CtuiApp): 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): super().__init__(**kwargs) self.connection = Connection(client_factory) + self.tags = TagStore(self.backend) self.statusbar = self.connection_status self._device_dispatches = set() self._project_changing = False @@ -94,7 +107,47 @@ def update_progress(self, completed, total): if hasattr(self, "app"): self.app.invalidate() - async def dispatch(self, text, **kwargs): # pylint: disable=too-many-branches + async def prepare_tag_import(self, text, tokens, kwargs): + """Prompt for project-specific tag collisions and return command text.""" + if len(tokens) < 3 or tokens[:2] != ["import", "tags"]: + return text, None + path_token = next( + (token for token in tokens[2:] if not token.startswith("-")), None + ) + if path_token is None or "--replace" in tokens: + return text, None + imported = parse_tag_document(Path(path_token).expanduser()) + collisions = sorted({tag.name for tag in imported} & await self.tags.names()) + if not collisions: + return text, None + message = ( + "Tags already exist: " + + ", ".join(collisions) + + ". Overwrite them? Re-run with --replace to overwrite without " + "prompting." + ) + callback = kwargs.get("confirm_callback") + if callback is None: + raise ConfirmationRequired(message) + approved = callback(message) + if asyncio.iscoroutine(approved): + approved = await approved + return ( + (text + " --replace", None) + if approved + else (text, CommandResult.rejected()) + ) + + # Lifecycle arbitration is intentionally centralized around command dispatch. + async def dispatch( # pylint: disable=too-many-branches,too-many-statements + self, text, **kwargs + ): + tokens = shlex.split(text) + # Collision names require reading both the file and active project. + text, rejected = await self.prepare_tag_import(text, tokens, kwargs) + if rejected is not None: + return rejected + # 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) @@ -150,6 +203,8 @@ async def dispatch(self, text, **kwargs): # pylint: disable=too-many-branches # recording session open; finish it in its original project. await self.finish_record_session() result = await super().dispatch(text, **kwargs) + 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( diff --git a/src/ctmodbus/operations.py b/src/ctmodbus/operations.py index 9111009..c6fc70f 100644 --- a/src/ctmodbus/operations.py +++ b/src/ctmodbus/operations.py @@ -55,8 +55,8 @@ class ModbusCommandMixin: async def record_operation(self, direction, decoded): """Overridden by the application to persist decoded protocol records.""" - async def read_values(self, kind, addresses, max_count): - """Read ordered chunks and retain completed results on failure.""" + async def read_values_data(self, kind, addresses, max_count): + """Read ordered chunks and return address/value pairs.""" chunks = list(read_chunks(addresses, max_count, READ_LIMITS[kind])) results = [] try: @@ -135,6 +135,11 @@ async def read_values(self, kind, addresses, max_count): 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): + """Read ordered chunks and format the completed values.""" + results = await self.read_values_data(kind, addresses, max_count) return CommandResult.append( f"{timestamp()} Read {kind}\n{format_values(kind, results)}" ) diff --git a/src/ctmodbus/tags.py b/src/ctmodbus/tags.py new file mode 100644 index 0000000..8dbcc8c --- /dev/null +++ b/src/ctmodbus/tags.py @@ -0,0 +1,579 @@ +"""Project-scoped typed Modbus tags and portable TOML files.""" + +from __future__ import annotations + +import json +import math +import os +import re +import struct +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +from ctui import Argument, CommandError, CommandResult, IntegerRanges, command +from tabulate import tabulate + +from ctmodbus.formatting import timestamp + +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") +INTEGER_PATTERN = re.compile( + r"(?P[+-]?)(?:(?P0[bBoOdDxX])(?P[0-9A-Fa-f_]+)|" + r"(?P[0-9][0-9_]*))\Z" +) + + +@dataclass(frozen=True) +class Tag: + """A named typed view of one bit or one or more registers.""" + + 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): + """Reject definitions that cannot be mapped to Modbus addresses.""" + 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 in the active ctui project database.""" + + 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): + self.backend = backend + + async def ensure(self): + """Create the application table for new projects when first used.""" + await self.backend.connection.execute(self.CREATE) + await self.backend.connection.commit() + + @staticmethod + def _row(row): + 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 one tag or raise a user-facing error.""" + 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 the active project's tag names.""" + return {tag.name for tag in await self.list()} + + async def save(self, tag, *, replace=False): + """Persist one validated tag.""" + 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 an existing tag.""" + 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 rename(self, name, new_name): + """Rename an existing tag without changing its definition.""" + 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): + """Import a validated collection in one transaction.""" + await self.ensure() + sql = ( + "INSERT OR REPLACE INTO tags VALUES (?, ?, ?, ?, ?, ?)" + if replace + else "INSERT INTO tags VALUES (?, ?, ?, ?, ?, ?)" + ) + await self.backend.connection.execute("BEGIN") + try: + for tag in tags: + 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: + await self.backend.connection.rollback() + raise + await self.backend.touch() + + +def _parse_integer(text): + 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 + + +def parse_tag_value(tag, text): + """Parse a natural typed value without depending on a raw byte syntax.""" + 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): + """Encode a typed value into unsigned 16-bit Modbus register values.""" + 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): + """Decode Modbus bits/registers according to a tag definition.""" + 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): + """Load and fully validate a versioned tag TOML document.""" + 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 + if document.get("format") != "ctmodbus-tags" or document.get("version") != 1: + raise CommandError("Unsupported tag export format") + 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) - { + "table", + "address", + "type", + "byte_order", + "word_order", + } + 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): + """Render deterministic TOML without adding another dependency.""" + 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, project-scoped Modbus tags.""" + + @command(name="tag 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 a typed tag; its address count is derived from its type.""" + 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="tag 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="tag 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="tag 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="tag delete", arguments={"name": Argument(completer=complete_tags)}) + async def tag_delete(self, name: str): + """Delete a tag from the active project.""" + await self.tags.delete(name) + return CommandResult.append(f"Deleted tag {name!r}") + + @command(name="read tags", arguments={"names": Argument(completer=complete_tags)}) + async def read_tags(self, names: list[str] | None = None): + """Read all tags, or comma-separated names in the requested order.""" + 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 = [] + for tag in tags: + addresses = str(tag.address) + if tag.count > 1: + addresses += f"-{tag.stop - 1}" + result = await self.read_values_data( + tag.table, + IntegerRanges(addresses), + min(tag.count, 2000 if tag.type == "bool" else 125), + ) + 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), + ) + ) + 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): + """Parse and write a value according to a tag's declared type.""" + 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="export tags") + async def export_tags(self, path: Path): + """Export all active-project tags to versioned TOML.""" + 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="import tags", + arguments={"replace": Argument(flags=("--replace",))}, + ) + async def import_tags(self, path: Path, replace: bool = False): + """Import a validated TOML tag set atomically.""" + tags = parse_tag_document(path) + 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/test_tags.py b/tests/test_tags.py new file mode 100644 index 0000000..68a017b --- /dev/null +++ b/tests/test_tags.py @@ -0,0 +1,223 @@ +"""Typed tag persistence, conversion, I/O, and interchange tests.""" + +import tempfile +import unittest +from pathlib import Path + +from ctui import CommandError, ConfirmationRequired + +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): + def test_integer_radices_and_byte_word_order(self): + 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): + 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): + 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): + self.assertIn("[tags]", export_tag_document([])) + + +class TagCommandTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + 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): + await self.app.on_stop() + await self.app.backend.close() + self.directory.cleanup() + + async def connect(self): + await self.app.dispatch("connect tcp localhost") + + async def test_create_list_rename_delete_and_project_scope(self): + await self.app.dispatch("tag create timer holding_register 2 int32") + result = await self.app.dispatch("tag list") + self.assertIn("timer", result.output) + self.assertIn("little", result.output) + result = await self.app.dispatch("tag show timer") + self.assertIn("range: 2-3", result.output) + await self.app.dispatch("tag rename timer duration") + self.assertEqual((await self.app.tags.get("duration")).address, 2) + await self.app.dispatch("tag delete duration") + self.assertEqual(await self.app.tags.list(), []) + + await self.app.dispatch("tag 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): + await self.app.dispatch("tag create first holding_register 0 uint32") + result = await self.app.dispatch("tag create second holding_register 1 uint16") + self.assertIn("overlaps tags: first", result.output) + with self.assertRaisesRegex(CommandError, "already exists"): + await self.app.dispatch("tag create first holding_register 3 uint16") + with self.assertRaisesRegex(CommandError, "do not accept"): + await self.app.dispatch("tag create bit coil 0 bool --byte-order little") + with self.assertRaisesRegex(CommandError, "must be one of"): + await self.app.dispatch("tag create old coils 0 bool") + + async def test_tag_reads_and_writes(self): + await self.app.dispatch("tag create switch coil 0 bool") + await self.app.dispatch("tag create state holding_register 5 uint8") + await self.app.dispatch("tag create timer holding_register 6 int32") + await self.app.dispatch("tag 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_read_all_requires_at_least_one_defined_tag(self): + with self.assertRaisesRegex(CommandError, "No tags are defined"): + await self.app.dispatch("read tags") + + async def test_export_import_collision_confirmation_and_replace(self): + await self.app.dispatch("tag create timer holding_register 2 int32") + path = Path(self.directory.name) / "my_tags" + result = await self.app.dispatch(f"export tags {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("tag delete timer") + await self.app.dispatch(f"import tags {exported}") + self.assertEqual((await self.app.tags.get("timer")).count, 2) + + with self.assertRaisesRegex(ConfirmationRequired, "timer"): + await self.app.dispatch(f"import tags {exported}") + messages = [] + + async def decline(message): + messages.append(message) + return False + + result = await self.app.dispatch( + f"import tags {exported}", confirm_callback=decline + ) + self.assertFalse(result.accepted) + self.assertIn("--replace", messages[0]) + result = await self.app.dispatch(f"import tags {exported} --replace") + self.assertIn("Imported 1 tags", result.output) + + async def test_import_is_validated_before_changes(self): + 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"import tags {path}") + self.assertEqual(await self.app.tags.list(), []) From 4718ddd46cecd01921f7bff971f8efa96e8013d5 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Sun, 20 Sep 2026 23:58:45 -0600 Subject: [PATCH 13/38] feat: show USB details in serial completion --- CHANGELOG.md | 1 + README.md | 8 +++++--- docs/STATUS.md | 16 ++++++++++++---- src/ctmodbus/discovery.py | 9 ++++++++- tests/test_discovery.py | 31 +++++++++++++++++++++++++++++++ 5 files changed, 57 insertions(+), 8 deletions(-) create mode 100644 tests/test_discovery.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 040d075..ed9ee6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [1.0.0rc1] - Unreleased +- Show USB manufacturer and product metadata in serial-device completions. - Add project-scoped typed tags with Boolean, signed/unsigned integer, and floating-point decoding; configurable byte/word order; tagged reads/writes; and atomic TOML import/export. diff --git a/README.md b/README.md index 731fb00..bb9b62c 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,11 @@ close ``` These examples show alternative connections: close the current session before -opening another. Serial-device discovery is advisory; device paths and symlinks -can be supplied even if enumeration does not list them. UDP opening establishes -a local socket and does not prove that a remote Modbus device is present. +opening another. Serial-device completion shows the USB manufacturer and product +beside each device path when the operating system provides them. Discovery is +advisory; device paths and symlinks can be supplied even if enumeration does not +list them. UDP opening establishes a local socket and does not prove that a +remote Modbus device is present. Addresses are **zero-based wire addresses**, from 0 through 65535, rather than 4xxxx reference numbers. Ranges are inclusive and preserve input order and diff --git a/docs/STATUS.md b/docs/STATUS.md index e303678..cb47e9d 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,7 +1,8 @@ # Project status -Last reconciled: 2026-09-20, `dev-v1.0.0` at `4e34098`, plus the uncommitted tag -feature described below. Working tree was clean at task start. +Last reconciled: 2026-09-20, `dev-v1.0.0` at `5eb04ee`, plus the uncommitted +serial-completion enhancement described below. Working tree was clean at task +start. ## Current state @@ -10,7 +11,10 @@ feature described below. Working tree was clean at task start. - Package version is `1.0.0rc1`; `ctui==1.0.0rc1` and `pymodbus[serial]>=3.15.0,<3.16` (locked to 3.15.0); Python >=3.11,<4. - Project-scoped typed tags are implemented with persistence, tagged reads and - writes, endian-aware integer/float codecs, and atomic TOML exchange (D11). + writes, endian-aware integer/float codecs, and atomic TOML exchange (D11), + committed at `5eb04ee`. +- Serial-device completion now shows USB manufacturer and product metadata when + the operating system provides it, with the device description as fallback. - The owner confirmed on September 18 that RC1 was unpublished. No local `v1.0.0rc1` tag exists; changelog remains Unreleased. Remote refs, PRs, publication state, and CI results were not refreshed for this review. @@ -53,8 +57,12 @@ manager, typing-marker ideas, and release plans are not ctmodbus commitments. - September 20 documentation edits on `d0e45e5`: content and local Markdown links reviewed; `git diff --check` passed. Runtime tests not run because no runtime or packaging behavior changed. -- September 20 tag implementation on `4e34098`: 53 tests passed on Python 3.11, +- September 20 tag implementation based on `4e34098`, subsequently committed at + `5eb04ee`: 53 tests passed on Python 3.11, including TCP/UDP, PTY serial, TLS, tag codecs/storage/I/O/import/export, and project isolation. Black, isort, pylint, lock, and whitespace checks passed; wheel and sdist builds and isolated installed-artifact smoke tests passed. Remote CI and manual terminal/hardware checks remain outstanding. +- September 20 serial-completion enhancement on `5eb04ee`: 30 focused discovery + and command tests passed on Python 3.11; black, isort, pylint, and whitespace + checks passed. Physical ports and terminal rendering remain to be checked. diff --git a/src/ctmodbus/discovery.py b/src/ctmodbus/discovery.py index e2f1b3e..8c0acd7 100644 --- a/src/ctmodbus/discovery.py +++ b/src/ctmodbus/discovery.py @@ -3,6 +3,7 @@ import asyncio import psutil +from ctui import CompletionItem from serial.tools.list_ports import comports from tabulate import tabulate @@ -14,7 +15,13 @@ def serial_devices(): async def complete_serial(_context): """ctui async completion provider; discovery is advisory, not a restriction.""" - return [item.device for item in await asyncio.to_thread(serial_devices)] + 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(): diff --git a/tests/test_discovery.py b/tests/test_discovery.py new file mode 100644 index 0000000..54abf53 --- /dev/null +++ b/tests/test_discovery.py @@ -0,0 +1,31 @@ +"""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): + async def test_serial_completion_includes_usb_identity(self): + 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") From 61df3a54c6ffb5ab2422c2ff3970541acdb107fe Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Mon, 21 Sep 2026 01:46:24 -0600 Subject: [PATCH 14/38] docs: document implementation contracts and reconcile project guidance --- AGENTS.md | 20 +++-- CHANGELOG.md | 4 +- MIGRATION.md | 90 ++++++-------------- README.md | 18 +++- RELEASE_CHECKLIST.md | 12 ++- docs/DECISIONS.md | 51 +++++++++++- docs/STATUS.md | 117 +++++++++++++------------- src/ctmodbus/__init__.py | 6 ++ src/ctmodbus/app.py | 85 +++++++++++++++++-- src/ctmodbus/commands.py | 13 ++- src/ctmodbus/connection.py | 77 ++++++++++++++--- src/ctmodbus/discovery.py | 26 +++++- src/ctmodbus/formatting.py | 21 ++++- src/ctmodbus/operations.py | 66 ++++++++++++--- src/ctmodbus/tags.py | 164 ++++++++++++++++++++++++++++++++----- tests/server.py | 16 ++++ tests/smoke_test.py | 6 ++ tests/test_commands.py | 55 +++++++++++++ tests/test_discovery.py | 3 + tests/test_integration.py | 14 ++++ tests/test_tags.py | 18 ++++ tests/test_tls.py | 26 ++++++ tests/test_tui.py | 6 ++ 23 files changed, 715 insertions(+), 199 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 32e2a11..3352dc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,13 +4,19 @@ 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; review D01–D08 together for cross-cutting architecture changes. Update +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). +Until preparing the `1.0.0rc1` release, run ctmodbus and its tests with the +adjacent ctui checkout by adding `--with-editable ../ctui` to `uv run`. This is +a temporary development override: do not change the declared ctui dependency +or lockfile merely to use the checkout. Release validation must omit the +override and use the intended published PyPI version. + ## Project constraints ctmodbus is an asynchronous Modbus device-testing tool using ctui and PyModbus. @@ -28,11 +34,11 @@ boundaries. Expansion beyond the agreed feature set requires an actual task. ```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 run --with-editable ../ctui ctmodbus +uv run --python 3.11 --with-editable ../ctui python -m unittest discover -s tests -v +uv run --with-editable ../ctui black --check src tests +uv run --with-editable ../ctui isort --check-only src tests +uv run --with-editable ../ctui pylint src/ctmodbus uv lock --check git diff --check uv build @@ -57,4 +63,4 @@ Publishing requires release approval; pushing a version tag triggers publication - `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 scope, user-facing changes, and release gates. + checklist describe migration guidance, user-facing changes, and release gates. diff --git a/CHANGELOG.md b/CHANGELOG.md index ed9ee6f..9c70e6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,8 @@ command, transport, persistence, cancellation, and terminal tests. - Consolidate packaging and align license metadata with the GPL-3.0-or-later source. -Breaking changes: no pre-1.0 API, command, or storage compatibility. Commands use -plural snake_case data names, comma-separated write values, and separate host +Breaking changes: no pre-1.0 API, command, or storage compatibility. Raw reads +and writes use plural snake_case names, comma-separated values, and separate host and `--port` arguments. Read counts are expressed as inclusive address ranges; `--max-count` controls request chunk size. Remove the development `debug eval` command. Python 3.8–3.10 are no longer supported. diff --git a/MIGRATION.md b/MIGRATION.md index 8a55511..227cf35 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,65 +1,25 @@ -# ctmodbus 1.0 migration - -Target: ctui 1.0.0rc1, Python 3.11+, native async PyModbus. -No compatibility layer for pre-1.0 commands or storage. - -Commit milestones: - -1. Packaging and application foundation. -2. Async connection lifecycle and discovery. -3. Typed commands, range reads, and acknowledged writes. -4. Project profiles, records, progress, and cancellation. -5. Automated regression and transport integration coverage. -6. Documentation, CI, distribution validation, and release checklist. - -Retain TCP/UDP/RTU/ASCII, discovery, identification, four read types, and -coil/register writes. Repair existing multi-write and response-validation gaps. -Defer polling, tags, simulation, proxying, raw/fuzzy requests, -tunneling, and historian integration. - -Release gates: command parity in TUI and CLI; nonblocking I/O; serialized -connection operations; bounded shutdown; project isolation; regression and -local transport tests; installed wheel/sdist smoke tests. Serial hardware and -interactive terminal checks must be recorded separately from automation. - -## Implemented - -All six milestones are implemented. The package remains at 1.0.0rc1 with -ctui==1.0.0rc1 and pymodbus[serial]>=3.15.0,<3.16 (locked to 3.15.0). -The new README documents the breaking command syntax and connection defaults. - -Local validation completed on Linux: - -- 34 automated tests passed on Python 3.11 and Python 3.14. -- Actual TCP/UDP and bridged-PTY RTU/ASCII exchanges passed. -- Injected-terminal TUI and sequential CLI/command-file execution passed. -- Cancellation, write uncertainty, project isolation, and recording failures - have regression coverage. -- Black, isort, pylint, and lockfile validation passed. -- Wheel and source distribution built; both passed isolated installation, - entry-point help, and project/config smoke checks. - -The GitHub workflow defines Python 3.11–3.14 checks on Linux, macOS, and Windows, -with distribution smoke tests required before publication. Those remote jobs -have not been run from this workspace. Physical serial hardware and real -terminal usability checks remain on RELEASE_CHECKLIST.md. No release was -published and no tags were pushed. - -## TLS follow-up - -Added native async `connect tls` after the initial migration, with server -verification, custom trust roots, optional client certificates, and profile -persistence. TLS integration uses ephemeral OpenSSL certificates and covers -verified reads/writes, mutual TLS, trust and hostname failures, and explicit -insecure connections. - -## Workflow alignment with ctui - -The test and publish workflows now follow ctui's workflow structure: Python -3.11–3.14 on Linux x86-64/ARM64, Windows, and macOS; dedicated quality checks; -post-test builds and installed-distribution checks; and artifact uploads. -Publication validates tag/package versions, extracts curated changelog notes, -attests and publishes the tested artifacts, and attaches them to GitHub releases. -Alpha, beta, and release-candidate tags are marked prerelease and never latest. -ctmodbus retains its pylint check; formatting targets omit ctui's examples -folder, and release notes/artifact labels use the ctmodbus name. +# 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. `tag 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 bb9b62c..87bde59 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,9 @@ Byte and word order do not apply to Boolean tags. coils and holding registers. Overlapping tags are allowed and reported when created because alternate interpretations can be useful. Tagged operations retain normal response validation, recording, cancellation, and uncertain-write -reporting. +reporting. Multi-tag reads currently issue one request per tag; if a later tag +fails, earlier values remain in operation records but are not included in the +failed command's output. `read tags` without names reads every tag in name order. Supply a comma-separated list to read only those tags while preserving the requested order and duplicates. @@ -137,6 +139,12 @@ the destination atomically. Import validates the whole file before changing the project. Existing names trigger a TUI confirmation; CLI and command-file use report the conflicting names and require `--replace`. +Tag imports and exports currently assume sequential use. See the +[documented concurrency limitations](docs/DECISIONS.md#d12--implementation-discrepancies-and-ctui-proposals) +before embedding concurrent tag-management or project-switching commands. + +## Connection options and operation results + All connections accept `--unit` (1–247), `--timeout` (seconds), and `--retries` (0–10). Defaults are unit 1, zero retries, and a timeout of 3 seconds for network connections or 1 second for serial connections. Serial defaults are 9600 baud, @@ -242,8 +250,12 @@ multi-write values, host:port syntax, and old storage formats are not supported. Polling, simulation, proxies, raw/fuzzy requests, tunneling, and historian integration remain deferred. -See [CHANGELOG.md](CHANGELOG.md), [MIGRATION.md](MIGRATION.md), and -[RELEASE_CHECKLIST.md](RELEASE_CHECKLIST.md) for scope and release gates. +See [MIGRATION.md](MIGRATION.md) for the 0.x transition and +[CHANGELOG.md](CHANGELOG.md) for release-facing changes. Current work and +validation gaps are in [docs/STATUS.md](docs/STATUS.md), architecture and rationale +in [docs/DECISIONS.md](docs/DECISIONS.md), and publication procedures in +[RELEASE_CHECKLIST.md](RELEASE_CHECKLIST.md). Local implementation contracts live +in module, class, and callable docstrings. ## License diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index d395815..942e8a1 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -4,6 +4,10 @@ Target release tag: `v1.0.0rc1`. Python package version: `1.0.0rc1`. ## Automated gates +Run every release gate without the development-only +`--with-editable ../ctui` override. Confirm `pyproject.toml` and `uv.lock` use +the intended ctui release from PyPI before starting. + - [ ] `uv lock --check` - [ ] `uv run black --check src tests` - [ ] `uv run isort --check-only src tests` @@ -26,11 +30,11 @@ Target release tag: `v1.0.0rc1`. Python package version: `1.0.0rc1`. - [ ] Publish only after release approval; creating/pushing a version tag triggers the existing trusted-PyPI publishing workflow. -## Local migration validation +## Recording validation -The migration is kept at 1.0.0rc1. Local results are recorded in MIGRATION.md. -Cross-platform CI and physical-device checks remain release gates even when the -local suite passes. This migration does not publish packages or push tags. +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 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index accd643..7d14c4c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -44,8 +44,8 @@ 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 data names, comma-separated write values, and explicit -named options. Host and port are separate, including IPv6. Do not restore old +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, @@ -146,7 +146,7 @@ future shared history. General commit preferences live in global instructions. ## D10 — Project memory and instruction scope -Accepted, 2026-09-18; scope refined by owner request, 2026-09-20. +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 @@ -158,6 +158,13 @@ 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. @@ -191,3 +198,41 @@ collision-specific TUI confirmation or explicit `--replace`. 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. + +- D02's whole-operation serialization and D04's partial-read reporting do not + fully extend across a multi-tag read. `TagCommandMixin.read_tags` reserves one + tag at a time and discards earlier displayed rows on later failure. Earlier + planning suggested adjacent-read batching and retained completed tags; neither + is implemented. Stored raw records still contain completed exchanges. +- `ModbusApp.prepare_tag_import` runs before lifecycle guards, matches only the + full command spelling, and rereads the file after confirmation. Tag commands + are not in the device-task guard set. Concurrent project changes or modified + import files can therefore invalidate the state that was confirmed. +- `TagStore.import_all` rolls back ordinary exceptions, but cancellation bypasses + that handler. Other services share its SQL connection and can commit during + awaits; atomic imports depend on noninterleaving use. Full reset clears the tag + table in a separate step after ctui's reset, rather than one transaction. +- 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. diff --git a/docs/STATUS.md b/docs/STATUS.md index cb47e9d..414e056 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,68 +1,75 @@ # Project status -Last reconciled: 2026-09-20, `dev-v1.0.0` at `5eb04ee`, plus the uncommitted -serial-completion enhancement described below. Working tree was clean at task -start. +Last reconciled: 2026-09-21, `dev-v1.0.0` at `4718ddd`, plus the uncommitted +documentation audit. Working tree was clean at task start. ## Current state -- The six migration milestones, TLS, and CI alignment are implemented through - `426048a`; `d0e45e5` added project instructions and continuity notes. -- Package version is `1.0.0rc1`; `ctui==1.0.0rc1` and - `pymodbus[serial]>=3.15.0,<3.16` (locked to 3.15.0); Python >=3.11,<4. -- Project-scoped typed tags are implemented with persistence, tagged reads and - writes, endian-aware integer/float codecs, and atomic TOML exchange (D11), - committed at `5eb04ee`. -- Serial-device completion now shows USB manufacturer and product metadata when - the operating system provides it, with the device description as fallback. -- The owner confirmed on September 18 that RC1 was unpublished. No local - `v1.0.0rc1` tag exists; changelog remains Unreleased. Remote refs, PRs, - publication state, and CI results were not refreshed for this review. -- The global/project instruction split is committed at `4e34098`. Global - instructions are machine-local and must be synchronized separately. +- 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 remains `1.0.0rc1`; dependencies are `ctui==1.0.0rc1` and + `pymodbus[serial]>=3.15.0,<3.16` (locked to 3.15.0), Python >=3.11,<4. +- During development, local commands and tests use the adjacent `../ctui` + checkout through `uv run --with-editable ../ctui`. This temporary override + leaves dependency metadata unchanged. Release validation must use the chosen + published PyPI ctui version without the override. +- The owner reported RC1 unpublished on September 18. Remote refs, CI, and + publication state have not been refreshed; CHANGELOG remains Unreleased. +- 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. +- Tag lifecycle, concurrency, and partial-output discrepancies are recorded in + [D12](DECISIONS.md#d12--implementation-discrepancies-and-ctui-proposals). + This documentation audit does not resolve those runtime issues. ## Next steps and validation gaps -1. Reconcile remote changes before integration or release work. Exercise tag - completion, collision confirmation, and typed output in a real terminal. -2. After feature work, follow [RELEASE_CHECKLIST.md](../RELEASE_CHECKLIST.md) on - the intended release revision: source/lockfile checks, wheel/sdist smoke - tests, metadata review, and verified remote CI across all 16 combinations. -3. Record real-terminal checks (completion, scrolling, progress/errors, - cancellation, exit cleanup) and RTU/ASCII checks on physical adapters/devices, - including revision, OS, Python, hardware, and outcomes. PTYs do not replace - physical checks. No subsequent manual validation has been reported. -4. Obtain release approval and complete publisher/changelog prerequisites before - tagging; verify publication using the release checklist. Before stable 1.0, - adopt tested stable ctui 1.0 and repeat release gates (D08). +1. Review and address D12's tag-read partial output/serialization and tag-import + confirmation/transaction/project isolation gaps with focused regressions. + ctui enhancements listed there remain proposals, not dependency commitments. +2. Exercise tag completion, collision confirmation, and typed output in a real + terminal; verify USB metadata on physical serial ports. +3. Before `1.0.0rc1` release validation, select the published PyPI ctui version, + update the dependency and lockfile if needed, and stop using the local + editable override. Then run the [release checklist](../RELEASE_CHECKLIST.md) + on the intended release revision, including the full platform matrix, + artifacts, terminal checks, and physical RTU/ASCII devices. Record actual + evidence here. +4. Obtain release approval before publication. Before stable 1.0, adopt tested + stable ctui and repeat release gates (D08). -No implementation blocker is established. Release readiness lacks verified -remote results and recorded manual hardware/terminal checks. +Polling, device cloning/simulation, proxies, raw/fuzzy requests, tunneling, and +historian integration remain deferred. No implementation blocker is established; +release readiness lacks final-revision and manual/remote validation. -## Deferred scope +## Validation evidence -Polling, device cloning/simulation, proxies, raw/fuzzy requests, tunneling, -and historian integration remain deferred. TLS is complete. ctui's optional job -manager, typing-marker ideas, and release plans are not ctmodbus commitments. +- 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. -## Validation evidence +## Audit source coverage -- Historical, September 13 through `426048a`: the prior local session reported - 45 passing tests, quality/lock/build checks, workflow checks, and isolated - wheel/sdist smoke tests. This supersedes the initial 34-test migration result - in [MIGRATION.md](../MIGRATION.md); it does not establish remote CI or hardware - success. Original evidence review is preserved in `d0e45e5`. -- September 18 at `d0e45e5`: documentation content, local links, and whitespace - reviewed; runtime tests not rerun. -- September 20 documentation edits on `d0e45e5`: content and local Markdown - links reviewed; `git diff --check` passed. Runtime tests not run because no - runtime or packaging behavior changed. -- September 20 tag implementation based on `4e34098`, subsequently committed at - `5eb04ee`: 53 tests passed on Python 3.11, - including TCP/UDP, PTY serial, TLS, tag codecs/storage/I/O/import/export, and - project isolation. Black, isort, pylint, lock, and whitespace checks passed; - wheel and sdist builds and isolated installed-artifact smoke tests passed. - Remote CI and manual terminal/hardware checks remain outstanding. -- September 20 serial-completion enhancement on `5eb04ee`: 30 focused discovery - and command tests passed on Python 3.11; black, isort, pylint, and whitespace - checks passed. Physical ports and terminal rendering remain to be checked. +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/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 index 06695af..5f594d3 100644 --- a/src/ctmodbus/app.py +++ b/src/ctmodbus/app.py @@ -1,4 +1,9 @@ -"""Application ownership and ctui lifecycle integration.""" +"""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 @@ -53,7 +58,14 @@ async def complete_profiles(context): class ModbusApp(TagCommandMixin, ModbusCommandMixin, CtuiApp): - """A Modbus client with one connection and project-scoped services.""" + """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") @@ -64,6 +76,11 @@ class ModbusApp(TagCommandMixin, ModbusCommandMixin, CtuiApp): 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.connection = Connection(client_factory) self.tags = TagStore(self.backend) @@ -102,13 +119,24 @@ def connection_status(self): return f"Project: {project} | {state}{self._progress}" def update_progress(self, completed, total): - """Refresh the TUI footer without affecting CLI output.""" + """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 prepare_tag_import(self, text, tokens, kwargs): - """Prompt for project-specific tag collisions and return command text.""" + """Return (command_text, rejected_result_or_None) for tag import preflight. + + For the exact ``import tags`` spelling without --replace, load the file + and current names. A synchronous/coroutine confirm_callback can approve + replacement or return a rejected result; absent callbacks raise + ConfirmationRequired listing collisions. Validation errors propagate. + This preflight precedes dispatch guards and does not reserve the project + or imported content while awaiting confirmation; see D12 discrepancies. + """ if len(tokens) < 3 or tokens[:2] != ["import", "tags"]: return text, None path_token = next( @@ -142,6 +170,15 @@ async def prepare_tag_import(self, text, tokens, kwargs): async def dispatch( # 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 + and preflight failures precede that boundary. Tag management/imports are + currently outside the device-task set; this is not a global command lock. + """ tokens = shlex.split(text) # Collision names require reading both the file and active project. text, rejected = await self.prepare_tag_import(text, tokens, kwargs) @@ -233,6 +270,12 @@ async def dispatch( # pylint: disable=too-many-branches,too-many-statements 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. + """ if self._record_session is None: return try: @@ -250,14 +293,23 @@ async def record_operation(self, direction, decoded): ) async def finish_record_session(self): - """End recording before switching connections or closing project storage.""" + """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): - """Drain device commands before transport and record-session cleanup.""" + """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: @@ -281,6 +333,7 @@ async def on_start(self): self._stopping = False async def on_stop(self): + """Reject new dispatches and await close_connection before backend closure.""" self._stopping = True await self.close_connection() @@ -290,7 +343,12 @@ async def connect_suggestions(self): return await asyncio.to_thread(suggestions) async def open_connection(self, settings): - """Open a transport and its project recording session together.""" + """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. + """ await self.connection.connect(settings) try: await self.finish_record_session() @@ -431,7 +489,12 @@ async def cancel(self): @command(name="profile save") async def profile_save(self, name: str): - """Save the current connection settings as a named project config.""" + """Save current settings under NAME and return an appended confirmation. + + Existing configs are replaced. Missing settings raise CommandError; + storage errors propagate. Profiles contain certificate paths, not keys + or certificate contents, and depend on those paths on the next machine. + """ if self.connection.settings is None: raise CommandError("Connect before saving a profile") await self.configs.save(name, self.connection.settings.as_dict()) @@ -442,7 +505,11 @@ async def profile_save(self, name: str): arguments={"name": Argument(completer=complete_profiles)}, ) async def profile_connect(self, name: str): - """Connect using a named project config.""" + """Load NAME, validate ConnectionSettings, and return open_connection result. + + Missing or invalid profiles raise CommandError before device I/O; + otherwise open_connection owns setup and failure cleanup. + """ values = await self.configs.get(name) try: settings = ConnectionSettings(**values) diff --git a/src/ctmodbus/commands.py b/src/ctmodbus/commands.py index bb3c73d..eb84907 100644 --- a/src/ctmodbus/commands.py +++ b/src/ctmodbus/commands.py @@ -1,10 +1,19 @@ -"""Console entry point.""" +"""Console entry point for the ctmodbus script and module execution. + +Delegate argument parsing and runtime ownership to ModbusApp/ctui; importing +this module does not launch the application. +""" from ctmodbus.app import ModbusApp def main(): - """Run the terminal UI or ctui's automatic command-line interface.""" + """Run TUI without arguments or sequential -c/-f CLI commands from argv. + + 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. No live application is retained globally. + """ ModbusApp().run() diff --git a/src/ctmodbus/connection.py b/src/ctmodbus/connection.py index 2f3af9a..da8f18f 100644 --- a/src/ctmodbus/connection.py +++ b/src/ctmodbus/connection.py @@ -1,4 +1,10 @@ -"""Native async transports and serialized, cancellable connection ownership.""" +"""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 @@ -23,7 +29,15 @@ @dataclass(frozen=True) class ConnectionSettings: - """Serializable settings; never persist a live transport.""" + """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 @@ -41,6 +55,7 @@ class ConnectionSettings: 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(): @@ -98,7 +113,13 @@ def label(self): def tls_context(settings): - """Load trust roots and optional client identity off the event loop.""" + """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 @@ -111,7 +132,12 @@ def tls_context(settings): async def create_client(settings): - """Construct a client in the running event loop; disable implicit reconnects.""" + """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, @@ -138,9 +164,15 @@ async def create_client(settings): class Connection: - """Own one client, invalidate stale work, and serialize wire transactions.""" + """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 @@ -154,7 +186,13 @@ def connected(self): return self.client is not None and self.client.connected async def connect(self, settings): - """Create and open a client, discarding failed or cancelled attempts.""" + """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) @@ -184,7 +222,12 @@ async def connect(self, settings): @asynccontextmanager async def operation(self): - """Reserve the current session for a complete command's requests.""" + """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; connect first") @@ -201,7 +244,11 @@ async def operation(self): self.tasks.discard(task) async def close(self): - """Cancel outstanding I/O before releasing the transport.""" + """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: @@ -214,14 +261,24 @@ async def close(self): self.client = self.settings = None def abort(self): - """Discard the transport after interrupted I/O and reject queued work.""" + """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): - """Bound each request even if a transport stops responding.""" + """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)): diff --git a/src/ctmodbus/discovery.py b/src/ctmodbus/discovery.py index 8c0acd7..0391b1f 100644 --- a/src/ctmodbus/discovery.py +++ b/src/ctmodbus/discovery.py @@ -1,4 +1,8 @@ -"""Local connection suggestions, performed off the event loop.""" +"""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 @@ -9,12 +13,21 @@ def serial_devices(): - """Enumerate ports in a stable display order.""" + """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): - """ctui async completion provider; discovery is advisory, not a restriction.""" + """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] @@ -25,7 +38,12 @@ async def complete_serial(_context): def suggestions(): - """Format local devices and best-effort process inspection.""" + """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() diff --git a/src/ctmodbus/formatting.py b/src/ctmodbus/formatting.py index 81b5c43..0f6892b 100644 --- a/src/ctmodbus/formatting.py +++ b/src/ctmodbus/formatting.py @@ -1,4 +1,8 @@ -"""Pure formatting for identical TUI and command-line results.""" +"""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 @@ -12,7 +16,13 @@ def timestamp(): def format_values(kind, values): - """Summarize consecutive equal values without hiding address gaps or order.""" + """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( @@ -39,7 +49,12 @@ def format_values(kind, values): def format_identification(information): - """Label sparse device objects and escape terminal control characters.""" + """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", diff --git a/src/ctmodbus/operations.py b/src/ctmodbus/operations.py index c6fc70f..b3132d3 100644 --- a/src/ctmodbus/operations.py +++ b/src/ctmodbus/operations.py @@ -1,4 +1,9 @@ -"""Typed Modbus commands, validation, and response handling.""" +"""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 @@ -23,7 +28,13 @@ def read_chunks(addresses, max_count, limit): - """Validate the entire request before yielding exclusive-endpoint spans.""" + """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): @@ -38,7 +49,12 @@ def read_chunks(addresses, max_count, limit): def validate_write(kind, address, values): - """Reject invalid writes before issuing any protocol request.""" + """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: @@ -50,13 +66,27 @@ def validate_write(kind, address, values): class ModbusCommandMixin: - """Commands mixed into ModbusApp; connection and services belong to the app.""" + """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): - """Overridden by the application to persist decoded protocol records.""" + """Return None by default; the application overrides this recording hook.""" async def read_values_data(self, kind, addresses, max_count): - """Read ordered chunks and return address/value pairs.""" + """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])) results = [] try: @@ -138,7 +168,10 @@ async def read_values_data(self, kind, addresses, max_count): return results async def read_values(self, kind, addresses, max_count): - """Read ordered chunks and format the completed values.""" + """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)}" @@ -146,7 +179,7 @@ async def read_values(self, kind, addresses, max_count): @staticmethod def partial_read(kind, results, message): - """Include completed addresses in a failed read's error output.""" + """Return message plus formatted completed pairs, or message if empty.""" if results: return ( f"{message}\nPartial read: {len(results)} addresses completed\n" @@ -182,7 +215,13 @@ async def read_holding_registers( @command(name="read id") async def read_id(self): - """Read basic device identification, including continued responses.""" + """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): @@ -240,7 +279,14 @@ async def read_id(self): ) async def write_values(self, kind, address, values): - """Issue one write and verify the returned function, address and 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" diff --git a/src/ctmodbus/tags.py b/src/ctmodbus/tags.py index 8dbcc8c..4b86e6c 100644 --- a/src/ctmodbus/tags.py +++ b/src/ctmodbus/tags.py @@ -1,4 +1,10 @@ -"""Project-scoped typed Modbus tags and portable TOML files.""" +"""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 @@ -48,7 +54,13 @@ @dataclass(frozen=True) class Tag: - """A named typed view of one bit or one or more registers.""" + """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 tag 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 @@ -71,7 +83,13 @@ def stop(self): return self.address + self.count def validate(self): - """Reject definitions that cannot be mapped to Modbus addresses.""" + """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 " @@ -111,22 +129,35 @@ def as_dict(self): class TagStore: - """Persist tags in the active ctui project database.""" + """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 application table for new projects when first used.""" + """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): @@ -139,7 +170,7 @@ async def list(self): return [self._row(row) for row in await cursor.fetchall()] async def get(self, name): - """Return one tag or raise a user-facing error.""" + """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 " @@ -152,11 +183,15 @@ async def get(self, name): return self._row(row) async def names(self): - """Return the active project's tag names.""" + """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): - """Persist one validated tag.""" + """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 = ( @@ -185,7 +220,7 @@ async def save(self, tag, *, replace=False): await self.backend.touch() async def delete(self, name): - """Delete an existing tag.""" + """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,) @@ -194,7 +229,11 @@ async def delete(self, name): await self.backend.touch() async def rename(self, name, new_name): - """Rename an existing tag without changing its definition.""" + """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: @@ -217,7 +256,13 @@ async def clear(self): await self.backend.touch() async def import_all(self, tags, *, replace=False): - """Import a validated collection in one transaction.""" + """Commit prevalidated tags in one transaction and return None. + + Caller must validate every definition and settle replacement policy + before calling. SQL errors roll back all rows and propagate. The later + metadata touch is separate from the committed transaction. Cancellation + is not caught by the ordinary-exception rollback handler. + """ await self.ensure() sql = ( "INSERT OR REPLACE INTO tags VALUES (?, ?, ?, ?, ?, ?)" @@ -246,6 +291,12 @@ async def import_all(self, tags, *, replace=False): def _parse_integer(text): + """Return a signed integer from decimal or 0b/0o/0d/0x text. + + Strip underscores from matched digits and accept an optional leading sign. + Malformed text or digits invalid for the radix raise ValueError. Width and + two's-complement interpretation are handled by callers. + """ match = INTEGER_PATTERN.fullmatch(text) if not match: raise ValueError("expected an integer or 0b/0o/0d/0x literal") @@ -257,7 +308,15 @@ def _parse_integer(text): def parse_tag_value(tag, text): - """Parse a natural typed value without depending on a raw byte syntax.""" + """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, @@ -297,7 +356,14 @@ def parse_tag_value(tag, text): def encode_tag_value(tag, value): - """Encode a typed value into unsigned 16-bit Modbus register values.""" + """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] @@ -316,7 +382,13 @@ def encode_tag_value(tag, value): def decode_tag_value(tag, values): - """Decode Modbus bits/registers according to a tag definition.""" + """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] @@ -332,7 +404,13 @@ def decode_tag_value(tag, values): def parse_tag_document(path): - """Load and fully validate a versioned tag TOML document.""" + """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) @@ -378,7 +456,11 @@ def parse_tag_document(path): def export_tag_document(tags): - """Render deterministic TOML without adding another dependency.""" + """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)}]")) @@ -400,7 +482,13 @@ async def complete_tags(context): class TagCommandMixin: - """Commands for typed, project-scoped Modbus tags.""" + """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="tag create", arguments=TAG_ORDER_ARGUMENTS) async def tag_create( @@ -424,7 +512,13 @@ async def tag_create( byte_order: Literal["big", "little"] | None = None, word_order: Literal["little", "big"] | None = None, ): - """Create a typed tag; its address count is derived from its type.""" + """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] @@ -496,7 +590,15 @@ async def tag_delete(self, name: str): @command(name="read tags", arguments={"names": Argument(completer=complete_tags)}) async def read_tags(self, names: list[str] | None = None): - """Read all tags, or comma-separated names in the requested order.""" + """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. Each tag gets a + separate read reservation, preserving explicit order and duplicates; + this is not a single snapshot. Return decoded and raw columns only after + all reads succeed. Earlier tag rows are currently lost on later failure; + read_values_data supplies only the failing tag's partial error output. + """ requested = names if requested is None: requested = [tag.name for tag in await self.tags.list()] @@ -529,7 +631,13 @@ async def read_tags(self, names: list[str] | None = None): @command(name="write tag", arguments={"name": Argument(completer=complete_tags)}) async def write_tag(self, name: str, value: str): - """Parse and write a value according to a tag's declared type.""" + """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") @@ -544,7 +652,13 @@ async def write_tag(self, name: str, value: str): @command(name="export tags") async def export_tags(self, path: Path): - """Export all active-project tags to versioned TOML.""" + """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") @@ -566,7 +680,13 @@ async def export_tags(self, path: Path): arguments={"replace": Argument(flags=("--replace",))}, ) async def import_tags(self, path: Path, replace: bool = False): - """Import a validated TOML tag set atomically.""" + """Validate PATH and merge tags, returning the imported count as text. + + Reject existing names unless replace=True. TUI confirmation is prepared + separately by ModbusApp.prepare_tag_import; direct calls never prompt. + The file is reread here. Ordinary SQL failures roll back imported rows; + cancellation and project races are documented as open discrepancies. + """ tags = parse_tag_document(path) collisions = sorted({tag.name for tag in tags} & await self.tags.names()) if collisions and not replace: diff --git a/tests/server.py b/tests/server.py index 6d1f9c9..91d01f3 100644 --- a/tests/server.py +++ b/tests/server.py @@ -13,6 +13,11 @@ 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" @@ -30,6 +35,12 @@ def device(): 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)) @@ -42,6 +53,11 @@ def make_server(transport, target="127.0.0.1", port=0): 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( diff --git a/tests/smoke_test.py b/tests/smoke_test.py index 616cd1d..7730dcd 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -9,6 +9,12 @@ 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( diff --git a/tests/test_commands.py b/tests/test_commands.py index cfb4f3b..ca9f7d0 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -16,11 +16,19 @@ 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 @@ -30,14 +38,23 @@ def __init__(self): 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) @@ -85,7 +102,10 @@ async def call(**kwargs): 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( @@ -94,14 +114,17 @@ async def asyncSetUp(self): 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("connect 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"connect {transport} target --unit 7 --timeout 0.2" @@ -110,6 +133,7 @@ async def test_all_transport_commands(self): await self.app.dispatch("close") 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" @@ -122,6 +146,7 @@ async def test_range_chunking_and_unit(self): 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", @@ -135,6 +160,7 @@ async def test_read_types_and_bit_padding(self): 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"), @@ -150,6 +176,7 @@ async def test_write_functions_and_acknowledgements(self): ) 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", @@ -170,6 +197,7 @@ async def test_invalid_arguments_do_not_send(self): 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), @@ -182,6 +210,7 @@ async def test_response_errors_and_short_reads(self): 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]) @@ -194,6 +223,7 @@ async def test_partial_read(self): 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]), @@ -207,6 +237,7 @@ async def test_bad_write_acknowledgements(self): 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) @@ -220,6 +251,7 @@ async def test_sparse_identification_and_continuation(self): 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 @@ -228,6 +260,7 @@ async def test_cyclic_identification(self): 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")) @@ -240,6 +273,7 @@ async def test_serialized_reads_and_responsive_help(self): 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")) @@ -254,6 +288,7 @@ async def test_cancel_read_and_queued_work(self): 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")) @@ -263,9 +298,11 @@ async def test_cancel_write_reports_uncertainty(self): 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() @@ -278,6 +315,7 @@ async def blocked(): 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")) @@ -288,6 +326,7 @@ async def test_shutdown_cancels_io(self): 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() @@ -295,17 +334,20 @@ async def test_failed_connect_cleanup(self): 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, "already open"): 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("profile save lab") await self.app.dispatch("read coils 0") @@ -329,10 +371,12 @@ async def test_profiles_records_history_and_project_isolation(self): 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) @@ -350,6 +394,7 @@ async def slow_start(*args, **kwargs): 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( @@ -359,6 +404,7 @@ async def test_record_warning_preserved_on_protocol_error(self): 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: @@ -386,6 +432,7 @@ async def test_cli_command_file_and_argument_failure(self): 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")) @@ -398,6 +445,7 @@ async def test_external_cancellation_then_project_change(self): 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"} ) @@ -405,6 +453,7 @@ async def test_invalid_profile(self): await self.app.dispatch("profile connect 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")) @@ -416,6 +465,7 @@ async def test_record_failure_keeps_acknowledged_write(self): 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() @@ -439,7 +489,10 @@ async def test_cli_sequence_and_cleanup(self): 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], @@ -447,6 +500,7 @@ def test_range_endpoints_and_order(self): ) def test_invalid_connection_settings(self): + """Reject out-of-range, non-finite, and unsupported settings at construction.""" for kwargs in ( {"unit": 0}, {"unit": 248}, @@ -464,6 +518,7 @@ def test_invalid_connection_settings(self): 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_discovery.py b/tests/test_discovery.py index 54abf53..0a23c3d 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -8,7 +8,10 @@ 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", diff --git a/tests/test_integration.py b/tests/test_integration.py index 1583c58..0d1b4d5 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -12,7 +12,14 @@ 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", @@ -34,6 +41,7 @@ async def exercise(self, app): 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": @@ -56,14 +64,17 @@ async def run_network(self, transport): 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() @@ -75,6 +86,7 @@ async def run_serial(self, transport): 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: @@ -107,8 +119,10 @@ def forward(source, destination): @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_tags.py b/tests/test_tags.py index 68a017b..f79fc31 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -18,7 +18,10 @@ 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) @@ -44,6 +47,7 @@ def test_integer_radices_and_byte_word_order(self): ) 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]) @@ -60,6 +64,7 @@ def test_eight_bit_padding_and_round_trips(self): 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): @@ -72,11 +77,15 @@ def test_value_and_definition_validation(self): 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() self.client = FakeClient() self.app = ModbusApp( @@ -85,14 +94,17 @@ async def asyncSetUp(self): 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("connect 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("tag create timer holding_register 2 int32") result = await self.app.dispatch("tag list") self.assertIn("timer", result.output) @@ -113,6 +125,7 @@ async def test_create_list_rename_delete_and_project_scope(self): 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("tag create first holding_register 0 uint32") result = await self.app.dispatch("tag create second holding_register 1 uint16") self.assertIn("overlaps tags: first", result.output) @@ -124,6 +137,7 @@ async def test_overlap_warning_and_duplicate_rejection(self): await self.app.dispatch("tag 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("tag create switch coil 0 bool") await self.app.dispatch("tag create state holding_register 5 uint8") await self.app.dispatch("tag create timer holding_register 6 int32") @@ -179,10 +193,12 @@ async def test_tag_reads_and_writes(self): self.assertEqual(len(self.client.calls), 4) 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("tag create timer holding_register 2 int32") path = Path(self.directory.name) / "my_tags" result = await self.app.dispatch(f"export tags {path}") @@ -198,6 +214,7 @@ async def test_export_import_collision_confirmation_and_replace(self): messages = [] async def decline(message): + """Capture the confirmation message and return False without changing tags.""" messages.append(message) return False @@ -210,6 +227,7 @@ async def decline(message): self.assertIn("Imported 1 tags", result.output) 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' diff --git a/tests/test_tls.py b/tests/test_tls.py index a5d7436..06caf26 100644 --- a/tests/test_tls.py +++ b/tests/test_tls.py @@ -20,10 +20,13 @@ 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 verification and certificate paths across profile save/connect.""" await self.app.dispatch("connect tls example.com") self.assertEqual(self.app.connection.settings.port, 802) self.assertFalse(self.app.connection.settings.insecure) @@ -39,14 +42,17 @@ async def test_defaults_and_profile_roundtrip(self): 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("connect 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) @@ -62,8 +68,15 @@ async def delayed(*args, **kwargs): @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" @@ -92,9 +105,11 @@ def setUpClass(cls): @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() @@ -107,18 +122,21 @@ async def asyncSetUp(self): 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'connect 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"connect tls localhost --port {self.port} --timeout 0.5" @@ -126,6 +144,7 @@ async def test_untrusted_certificate_rejected(self): 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'connect tls 127.0.0.1 --port {self.port} --ca-file "{self.cert}" --timeout 0.5' @@ -133,11 +152,13 @@ async def test_hostname_mismatch_rejected(self): 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"connect 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'connect tls localhost --port {self.port} --ca-file "{self.cert}.missing"' @@ -147,6 +168,7 @@ async def test_missing_certificate_file(self): 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) @@ -165,12 +187,16 @@ async def test_client_certificate(self): 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 index 1e4b875..28a8940 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -13,12 +13,18 @@ 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: From cf3fb59f6a68a86bda9756b09af312d4c7915844 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Mon, 21 Sep 2026 02:04:29 -0600 Subject: [PATCH 15/38] docs: require help for positional command arguments --- AGENTS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 3352dc7..670d239 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,11 @@ 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. + ## Commands and verification ```bash From e0eec09962b1b077e37c2351457e01510b9d19f7 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Mon, 21 Sep 2026 02:39:42 -0600 Subject: [PATCH 16/38] fix: group tag import and export commands --- CHANGELOG.md | 2 +- README.md | 4 ++-- docs/DECISIONS.md | 3 ++- docs/STATUS.md | 9 +++++++-- src/ctmodbus/app.py | 4 ++-- src/ctmodbus/tags.py | 8 ++++---- tests/test_tags.py | 12 ++++++------ 7 files changed, 24 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c70e6c..3371188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ - Show USB manufacturer and product metadata in serial-device completions. - Add project-scoped typed tags with Boolean, signed/unsigned integer, and floating-point decoding; configurable byte/word order; tagged reads/writes; - and atomic TOML import/export. + and atomic TOML import/export through `tag export` and `tag import`. - Add `connect tls` with verified server certificates, custom CA roots, optional client identity, explicit insecure mode, and persisted TLS connection settings. diff --git a/README.md b/README.md index 87bde59..3381e2a 100644 --- a/README.md +++ b/README.md @@ -98,8 +98,8 @@ write tag state 0b0111_0001 write tag pause_sw on tag rename timer duration tag delete duration -export tags my_tags -import tags my_tags.toml +tag export my_tags +tag import my_tags.toml ``` Types are `bool`, signed and unsigned 8-, 16-, 32-, and 64-bit integers, and diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 7d14c4c..16d535a 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -193,7 +193,8 @@ 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`. +collision-specific TUI confirmation or explicit `--replace`. Keep file +operations within the tag command group as `tag export` and `tag import`. Tagged I/O reuses the existing serialized read/write path, response checks, recording, cancellation, and uncertain-write reporting. Tag definitions are diff --git a/docs/STATUS.md b/docs/STATUS.md index 414e056..a4a1ecf 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,7 +1,7 @@ # Project status -Last reconciled: 2026-09-21, `dev-v1.0.0` at `4718ddd`, plus the uncommitted -documentation audit. Working tree was clean at task start. +Last reconciled: 2026-09-21, `dev-v1.0.0` at `cf3fb59`, plus the uncommitted tag +import/export command rename. Working tree was clean at task start. ## Current state @@ -22,6 +22,9 @@ documentation audit. Working tree was clean at task start. - Tag lifecycle, concurrency, and partial-output discrepancies are recorded in [D12](DECISIONS.md#d12--implementation-discrepancies-and-ctui-proposals). This documentation audit does not resolve those runtime issues. +- Tag TOML commands are grouped with the other tag-management commands as + `tag export` and `tag import`; the unreleased `export tags` and `import tags` + spellings are not retained as aliases. ## Next steps and validation gaps @@ -63,6 +66,8 @@ release readiness lacks final-revision and manual/remote validation. 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 on `cf3fb59`: all 10 focused tag tests passed + on Python 3.11 using the adjacent editable ctui checkout. ## Audit source coverage diff --git a/src/ctmodbus/app.py b/src/ctmodbus/app.py index 5f594d3..bc28593 100644 --- a/src/ctmodbus/app.py +++ b/src/ctmodbus/app.py @@ -130,14 +130,14 @@ def update_progress(self, completed, total): async def prepare_tag_import(self, text, tokens, kwargs): """Return (command_text, rejected_result_or_None) for tag import preflight. - For the exact ``import tags`` spelling without --replace, load the file + For the exact ``tag import`` spelling without --replace, load the file and current names. A synchronous/coroutine confirm_callback can approve replacement or return a rejected result; absent callbacks raise ConfirmationRequired listing collisions. Validation errors propagate. This preflight precedes dispatch guards and does not reserve the project or imported content while awaiting confirmation; see D12 discrepancies. """ - if len(tokens) < 3 or tokens[:2] != ["import", "tags"]: + if len(tokens) < 3 or tokens[:2] != ["tag", "import"]: return text, None path_token = next( (token for token in tokens[2:] if not token.startswith("-")), None diff --git a/src/ctmodbus/tags.py b/src/ctmodbus/tags.py index 4b86e6c..9219a19 100644 --- a/src/ctmodbus/tags.py +++ b/src/ctmodbus/tags.py @@ -650,8 +650,8 @@ async def write_tag(self, name: str, value: str): f"{result.output}" ) - @command(name="export tags") - async def export_tags(self, path: Path): + @command(name="tag export") + 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. @@ -676,10 +676,10 @@ async def export_tags(self, path: Path): return f"Exported tags to {path}." @command( - name="import tags", + name="tag import", arguments={"replace": Argument(flags=("--replace",))}, ) - async def import_tags(self, path: Path, replace: bool = False): + async def tag_import(self, path: Path, replace: bool = False): """Validate PATH and merge tags, returning the imported count as text. Reject existing names unless replace=True. TUI confirmation is prepared diff --git a/tests/test_tags.py b/tests/test_tags.py index f79fc31..83b62f2 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -201,16 +201,16 @@ 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("tag create timer holding_register 2 int32") path = Path(self.directory.name) / "my_tags" - result = await self.app.dispatch(f"export tags {path}") + result = await self.app.dispatch(f"tag export {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("tag delete timer") - await self.app.dispatch(f"import tags {exported}") + await self.app.dispatch(f"tag import {exported}") self.assertEqual((await self.app.tags.get("timer")).count, 2) with self.assertRaisesRegex(ConfirmationRequired, "timer"): - await self.app.dispatch(f"import tags {exported}") + await self.app.dispatch(f"tag import {exported}") messages = [] async def decline(message): @@ -219,11 +219,11 @@ async def decline(message): return False result = await self.app.dispatch( - f"import tags {exported}", confirm_callback=decline + f"tag import {exported}", confirm_callback=decline ) self.assertFalse(result.accepted) self.assertIn("--replace", messages[0]) - result = await self.app.dispatch(f"import tags {exported} --replace") + result = await self.app.dispatch(f"tag import {exported} --replace") self.assertIn("Imported 1 tags", result.output) async def test_import_is_validated_before_changes(self): @@ -237,5 +237,5 @@ async def test_import_is_validated_before_changes(self): encoding="utf-8", ) with self.assertRaisesRegex(CommandError, "Invalid tag 'bad'"): - await self.app.dispatch(f"import tags {path}") + await self.app.dispatch(f"tag import {path}") self.assertEqual(await self.app.tags.list(), []) From 6fd5c2162f7dc20fe15f0e70ea995f8c2630103e Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Mon, 21 Sep 2026 02:50:40 -0600 Subject: [PATCH 17/38] feat: complete tag import and export paths --- CHANGELOG.md | 3 ++- README.md | 5 +++-- docs/DECISIONS.md | 3 ++- docs/STATUS.md | 8 +++++--- src/ctmodbus/tags.py | 21 ++++++++++++++++++--- tests/test_tags.py | 9 ++++++++- 6 files changed, 38 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3371188..537ab12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,8 @@ - Show USB manufacturer and product metadata in serial-device completions. - Add project-scoped typed tags with Boolean, signed/unsigned integer, and floating-point decoding; configurable byte/word order; tagged reads/writes; - and atomic TOML import/export through `tag export` and `tag import`. + and atomic TOML import/export with path completion through `tag export` and + `tag import`. - Add `connect tls` with verified server certificates, custom CA roots, optional client identity, explicit insecure mode, and persisted TLS connection settings. diff --git a/README.md b/README.md index 3381e2a..5b79611 100644 --- a/README.md +++ b/README.md @@ -136,8 +136,9 @@ list to read only those tags while preserving the requested order and duplicates Tag files are versioned TOML. Export appends `.toml` when absent and replaces the destination atomically. Import validates the whole file before changing the -project. Existing names trigger a TUI confirmation; CLI and command-file use -report the conflicting names and require `--replace`. +project. Both commands suggest filesystem paths in the TUI. Existing names +trigger a TUI confirmation; CLI and command-file use report the conflicting +names and require `--replace`. Tag imports and exports currently assume sequential use. See the [documented concurrency limitations](docs/DECISIONS.md#d12--implementation-discrepancies-and-ctui-proposals) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 16d535a..f4599f4 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -194,7 +194,8 @@ 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 `tag export` and `tag import`. +operations within the tag command group as `tag export` and `tag import`; both +use ctui's shared path completer. Tagged I/O reuses the existing serialized read/write path, response checks, recording, cancellation, and uncertain-write reporting. Tag definitions are diff --git a/docs/STATUS.md b/docs/STATUS.md index a4a1ecf..0a2a758 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -24,7 +24,8 @@ import/export command rename. Working tree was clean at task start. This documentation audit does not resolve those runtime issues. - Tag TOML commands are grouped with the other tag-management commands as `tag export` and `tag import`; the unreleased `export tags` and `import tags` - spellings are not retained as aliases. + spellings are not retained as aliases. Both path arguments use the shared + ctui path completer from the adjacent development checkout. ## Next steps and validation gaps @@ -66,8 +67,9 @@ release readiness lacks final-revision and manual/remote validation. 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 on `cf3fb59`: all 10 focused tag tests passed - on Python 3.11 using the adjacent editable ctui checkout. +- 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 diff --git a/src/ctmodbus/tags.py b/src/ctmodbus/tags.py index 9219a19..bad1685 100644 --- a/src/ctmodbus/tags.py +++ b/src/ctmodbus/tags.py @@ -18,7 +18,14 @@ from pathlib import Path from typing import Literal -from ctui import Argument, CommandError, CommandResult, IntegerRanges, command +from ctui import ( + Argument, + CommandError, + CommandResult, + IntegerRanges, + PathCompleter, + command, +) from tabulate import tabulate from ctmodbus.formatting import timestamp @@ -650,7 +657,12 @@ async def write_tag(self, name: str, value: str): f"{result.output}" ) - @command(name="tag export") + @command( + name="tag 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. @@ -677,7 +689,10 @@ async def tag_export(self, path: Path): @command( name="tag import", - arguments={"replace": Argument(flags=("--replace",))}, + 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. diff --git a/tests/test_tags.py b/tests/test_tags.py index 83b62f2..126edb6 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -4,7 +4,7 @@ import unittest from pathlib import Path -from ctui import CommandError, ConfirmationRequired +from ctui import CommandError, ConfirmationRequired, PathCompleter from ctmodbus.app import ModbusApp from ctmodbus.tags import ( @@ -226,6 +226,13 @@ async def decline(message): result = await self.app.dispatch(f"tag import {exported} --replace") self.assertIn("Imported 1 tags", result.output) + def test_import_and_export_use_path_completion(self): + """Expose ctui filesystem suggestions on both tag-file path arguments.""" + for name in ("tag import", "tag 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" From 920daa0dea89d27278f64309e063c72a391d6dfb Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Fri, 2 Oct 2026 10:56:23 -0700 Subject: [PATCH 18/38] feat: serialize multi-tag reads and retain partial results --- CHANGELOG.md | 3 ++ README.md | 8 +-- docs/DECISIONS.md | 9 ++-- docs/STATUS.md | 18 +++++-- src/ctmodbus/operations.py | 107 ++++++++++++++++++++----------------- src/ctmodbus/tags.py | 74 ++++++++++++++++--------- tests/test_commands.py | 4 +- tests/test_tags.py | 89 ++++++++++++++++++++++++++++++ 8 files changed, 225 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 537ab12..487374c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [1.0.0rc1] - Unreleased +- Reserve the connection across multi-tag reads and preserve completed decoded + results on failure or cancellation, identifying failed and unread tags. + - Show USB manufacturer and product metadata in serial-device completions. - Add project-scoped typed tags with Boolean, signed/unsigned integer, and floating-point decoding; configurable byte/word order; tagged reads/writes; diff --git a/README.md b/README.md index 5b79611..de8723f 100644 --- a/README.md +++ b/README.md @@ -127,9 +127,11 @@ Byte and word order do not apply to Boolean tags. coils and holding registers. Overlapping tags are allowed and reported when created because alternate interpretations can be useful. Tagged operations retain normal response validation, recording, cancellation, and uncertain-write -reporting. Multi-tag reads currently issue one request per tag; if a later tag -fails, earlier values remain in operation records but are not included in the -failed command's output. +reporting. Multi-tag reads hold one connection reservation across the command, +with one request per tag. Failure or cancellation preserves completed decoded +rows, identifies the failed tag, and reports how many remaining tags were not +read. Cancellation closes the connection; reconnect before further device I/O. +This prevents command interleaving, not changes to device values between reads. `read tags` without names reads every tag in name order. Supply a comma-separated list to read only those tags while preserving the requested order and duplicates. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index f4599f4..bf22988 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -212,11 +212,10 @@ model; any such association would be session-local and is not implemented. Audit findings, 2026-09-21, against `4718ddd`; unresolved, not new accepted policy. -- D02's whole-operation serialization and D04's partial-read reporting do not - fully extend across a multi-tag read. `TagCommandMixin.read_tags` reserves one - tag at a time and discards earlier displayed rows on later failure. Earlier - planning suggested adjacent-read batching and retained completed tags; neither - is implemented. Stored raw records still contain completed exchanges. +- 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. - `ModbusApp.prepare_tag_import` runs before lifecycle guards, matches only the full command spelling, and rereads the file after confirmation. Tag commands are not in the device-task guard set. Concurrent project changes or modified diff --git a/docs/STATUS.md b/docs/STATUS.md index 0a2a758..7fe0383 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,7 +1,7 @@ # Project status -Last reconciled: 2026-09-21, `dev-v1.0.0` at `cf3fb59`, plus the uncommitted tag -import/export command rename. Working tree was clean at task start. +Last reconciled: 2026-10-02, `dev-v1.0.0` based on `6fd5c21`, with local +multi-tag read serialization and partial-output changes. ## Current state @@ -29,8 +29,9 @@ import/export command rename. Working tree was clean at task start. ## Next steps and validation gaps -1. Review and address D12's tag-read partial output/serialization and tag-import - confirmation/transaction/project isolation gaps with focused regressions. +1. Address D12's remaining tag-import confirmation/transaction/project isolation + gaps with focused regressions. Multi-tag serialization/partial output is fixed; + adjacent-read batching remains future work. ctui enhancements listed there remain proposals, not dependency commitments. 2. Exercise tag completion, collision confirmation, and typed output in a real terminal; verify USB metadata on physical serial ports. @@ -49,6 +50,15 @@ release readiness lacks final-revision and manual/remote validation. ## Validation evidence +- 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. diff --git a/src/ctmodbus/operations.py b/src/ctmodbus/operations.py index b3132d3..6c2480c 100644 --- a/src/ctmodbus/operations.py +++ b/src/ctmodbus/operations.py @@ -88,58 +88,67 @@ async def read_values_data(self, kind, addresses, max_count): transport and becomes CommandError with the same partial results. """ chunks = list(read_chunks(addresses, max_count, READ_LIMITS[kind])) - results = [] try: async with self.connection.operation() as (client, settings): - 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) + 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( diff --git a/src/ctmodbus/tags.py b/src/ctmodbus/tags.py index bad1685..8aea477 100644 --- a/src/ctmodbus/tags.py +++ b/src/ctmodbus/tags.py @@ -8,6 +8,7 @@ from __future__ import annotations +import asyncio import json import math import os @@ -22,7 +23,7 @@ Argument, CommandError, CommandResult, - IntegerRanges, + IntegerSpan, PathCompleter, command, ) @@ -595,16 +596,24 @@ async def tag_delete(self, name: str): await self.tags.delete(name) return CommandResult.append(f"Deleted tag {name!r}") - @command(name="read tags", arguments={"names": Argument(completer=complete_tags)}) + @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. Each tag gets a - separate read reservation, preserving explicit order and duplicates; - this is not a single snapshot. Return decoded and raw columns only after - all reads succeed. Earlier tag rows are currently lost on later failure; - read_values_data supplies only the failing tag's partial error output. + 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: @@ -613,24 +622,41 @@ async def read_tags(self, names: list[str] | None = None): raise CommandError("No tags are defined in the active project") tags = [await self.tags.get(name) for name in requested] rows = [] - for tag in tags: - addresses = str(tag.address) - if tag.count > 1: - addresses += f"-{tag.stop - 1}" - result = await self.read_values_data( - tag.table, - IntegerRanges(addresses), - min(tag.count, 2000 if tag.type == "bool" else 125), - ) - 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), + 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")) diff --git a/tests/test_commands.py b/tests/test_commands.py index ca9f7d0..db243d1 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -266,8 +266,8 @@ async def test_serialized_reads_and_responsive_help(self): 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"), 1) - self.assertIn("read coils", result.output) + 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) diff --git a/tests/test_tags.py b/tests/test_tags.py index 126edb6..8ce60ba 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -1,5 +1,6 @@ """Typed tag persistence, conversion, I/O, and interchange tests.""" +import asyncio import tempfile import unittest from pathlib import Path @@ -192,6 +193,94 @@ async def test_tag_reads_and_writes(self): 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("tag create first holding_register 1 uint16") + await self.app.dispatch("tag 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"tag 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"tag 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"tag 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"): From 2c9afc728b4969b82f43d0ba381269d4d6d715ec Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Fri, 2 Oct 2026 11:05:22 -0700 Subject: [PATCH 19/38] feat: make tag imports atomic and preserve confirmed data --- CHANGELOG.md | 3 ++ README.md | 6 ++- docs/DECISIONS.md | 18 ++++--- docs/STATUS.md | 22 +++++--- src/ctmodbus/app.py | 104 +++++++++++++++++++++++--------------- src/ctmodbus/tags.py | 60 +++++++++++++--------- tests/test_tags.py | 117 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 247 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 487374c..110e783 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [1.0.0rc1] - Unreleased +- Import the exact confirmed tag data, guard project/tag changes during import, + and apply tag merges in an isolated all-or-none transaction. + - Reserve the connection across multi-tag reads and preserve completed decoded results on failure or cancellation, identifying failed and unread tags. diff --git a/README.md b/README.md index de8723f..004d279 100644 --- a/README.md +++ b/README.md @@ -138,11 +138,13 @@ list to read only those tags while preserving the requested order and duplicates Tag files are versioned TOML. Export appends `.toml` when absent and replaces the destination atomically. Import validates the whole file before changing the -project. Both commands suggest filesystem paths in the TUI. Existing names +project. Imports retain the exact validated content during confirmation and +reserve the project and tag edits until finished. The tag merge commits all +rows or none. Both commands suggest filesystem paths in the TUI. Existing names trigger a TUI confirmation; CLI and command-file use report the conflicting names and require `--replace`. -Tag imports and exports currently assume sequential use. See the +Concurrent exports still assume sequential use. See the [documented concurrency limitations](docs/DECISIONS.md#d12--implementation-discrepancies-and-ctui-proposals) before embedding concurrent tag-management or project-switching commands. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index bf22988..5adaa67 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -216,14 +216,16 @@ Audit findings, 2026-09-21, against `4718ddd`; unresolved, not new accepted poli 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. -- `ModbusApp.prepare_tag_import` runs before lifecycle guards, matches only the - full command spelling, and rereads the file after confirmation. Tag commands - are not in the device-task guard set. Concurrent project changes or modified - import files can therefore invalidate the state that was confirmed. -- `TagStore.import_all` rolls back ordinary exceptions, but cancellation bypasses - that handler. Other services share its SQL connection and can commit during - awaits; atomic imports depend on noninterleaving use. Full reset clears the tag - table in a separate step after ctui's reset, rather than one transaction. +- 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. diff --git a/docs/STATUS.md b/docs/STATUS.md index 7fe0383..ab89cb7 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,7 +1,7 @@ # Project status -Last reconciled: 2026-10-02, `dev-v1.0.0` based on `6fd5c21`, with local -multi-tag read serialization and partial-output changes. +Last reconciled: 2026-10-02, `dev-v1.0.0` based on `920daa0`, with local +prepared tag-import and transaction-safety changes. ## Current state @@ -19,9 +19,8 @@ multi-tag read serialization and partial-output changes. - 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. -- Tag lifecycle, concurrency, and partial-output discrepancies are recorded in +- Resolved and remaining tag lifecycle/concurrency discrepancies are recorded in [D12](DECISIONS.md#d12--implementation-discrepancies-and-ctui-proposals). - This documentation audit does not resolve those runtime issues. - Tag TOML commands are grouped with the other tag-management commands as `tag export` and `tag import`; the unreleased `export tags` and `import tags` spellings are not retained as aliases. Both path arguments use the shared @@ -29,9 +28,10 @@ multi-tag read serialization and partial-output changes. ## Next steps and validation gaps -1. Address D12's remaining tag-import confirmation/transaction/project isolation - gaps with focused regressions. Multi-tag serialization/partial output is fixed; - adjacent-read batching remains future work. +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. Exercise tag completion, collision confirmation, and typed output in a real terminal; verify USB metadata on physical serial ports. @@ -50,6 +50,14 @@ release readiness lacks final-revision and manual/remote validation. ## Validation evidence +- 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, diff --git a/src/ctmodbus/app.py b/src/ctmodbus/app.py index bc28593..fc8678c 100644 --- a/src/ctmodbus/app.py +++ b/src/ctmodbus/app.py @@ -9,7 +9,6 @@ import shlex from dataclasses import replace from importlib.metadata import version -from pathlib import Path from typing import Literal from ctui import ( @@ -87,6 +86,9 @@ def __init__(self, *, client_factory=create_client, **kwargs): self.statusbar = self.connection_status self._device_dispatches = set() self._project_changing = False + self._tag_import_task = None + self._tag_mutations = set() + self._prepared_tag_imports = {} self._closing = False self._opening = False self._stopping = False @@ -127,44 +129,39 @@ def update_progress(self, completed, total): if hasattr(self, "app"): self.app.invalidate() - async def prepare_tag_import(self, text, tokens, kwargs): - """Return (command_text, rejected_result_or_None) for tag import preflight. + async def prepare_tag_import(self, text, kwargs): + """Prepare exact import data under the dispatch project/tag guard. - For the exact ``tag import`` spelling without --replace, load the file - and current names. A synchronous/coroutine confirm_callback can approve - replacement or return a rejected result; absent callbacks raise - ConfirmationRequired listing collisions. Validation errors propagate. - This preflight precedes dispatch guards and does not reserve the project - or imported content while awaiting confirmation; see D12 discrepancies. + 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. """ - if len(tokens) < 3 or tokens[:2] != ["tag", "import"]: - return text, None - path_token = next( - (token for token in tokens[2:] if not token.startswith("-")), None - ) - if path_token is None or "--replace" in tokens: - return text, None - imported = parse_tag_document(Path(path_token).expanduser()) + 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 not collisions: - return text, None - message = ( - "Tags already exist: " - + ", ".join(collisions) - + ". Overwrite them? Re-run with --replace to overwrite without " - "prompting." - ) - callback = kwargs.get("confirm_callback") - if callback is None: - raise ConfirmationRequired(message) - approved = callback(message) - if asyncio.iscoroutine(approved): - approved = await approved - return ( - (text + " --replace", None) - if approved - else (text, CommandResult.rejected()) + if collisions and not replace_existing: + message = ( + "Tags already exist: " + + ", ".join(collisions) + + ". Overwrite them? Re-run with --replace to overwrite " + "without prompting." + ) + callback = kwargs.get("confirm_callback") + if callback is None: + raise ConfirmationRequired(message) + approved = callback(message) + if asyncio.iscoroutine(approved): + approved = await approved + if not approved: + return CommandResult.rejected() + replace_existing = True + self._prepared_tag_imports[asyncio.current_task()] = ( + imported, + replace_existing, ) + return None # Lifecycle arbitration is intentionally centralized around command dispatch. async def dispatch( # pylint: disable=too-many-branches,too-many-statements @@ -176,14 +173,10 @@ async def dispatch( # pylint: disable=too-many-branches,too-many-statements 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 - and preflight failures precede that boundary. Tag management/imports are - currently outside the device-task set; this is not a global command lock. + precede that boundary. Imports reserve the project and tag mutations across + confirmation and application; other commands remain available. """ tokens = shlex.split(text) - # Collision names require reading both the file and active project. - text, rejected = await self.prepare_tag_import(text, tokens, kwargs) - if rejected is not None: - return rejected # Reserve project transitions before the first await. This also covers # unique command prefixes, which are resolved to their canonical names. @@ -201,10 +194,22 @@ async def dispatch( # pylint: disable=too-many-branches,too-many-statements task = asyncio.current_task() if self._stopping: raise CommandError("Application is stopping") + importing = item.name == "tag import" + tag_mutation = item.name in { + "tag create", + "tag rename", + "tag delete", + "tag import", + } + if self._tag_import_task is not None and (project_change or tag_mutation): + 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 ): @@ -234,7 +239,15 @@ async def dispatch( # pylint: disable=too-many-branches,too-many-statements self._opening = False raise CommandError("Connection is closing; retry when it finishes") self._device_dispatches.add(task) + if tag_mutation: + self._tag_mutations.add(task) + if importing: + self._tag_import_task = task try: + 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. @@ -261,6 +274,11 @@ async def dispatch( # pylint: disable=too-many-branches,too-many-statements ) from error raise finally: + if tag_mutation: + self._tag_mutations.discard(task) + if importing: + self._prepared_tag_imports.pop(task, None) + self._tag_import_task = None if project_change: self._project_changing = False if device_command: @@ -335,6 +353,10 @@ async def on_start(self): async def on_stop(self): """Reject new dispatches and await close_connection before backend closure.""" self._stopping = True + importing = self._tag_import_task + if importing is not None and importing is not asyncio.current_task(): + importing.cancel() + await asyncio.gather(importing, return_exceptions=True) await self.close_connection() @command(name="connect") diff --git a/src/ctmodbus/tags.py b/src/ctmodbus/tags.py index 8aea477..492ddf9 100644 --- a/src/ctmodbus/tags.py +++ b/src/ctmodbus/tags.py @@ -13,8 +13,10 @@ 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 @@ -266,35 +268,40 @@ async def clear(self): async def import_all(self, tags, *, replace=False): """Commit prevalidated tags in one transaction and return None. - Caller must validate every definition and settle replacement policy - before calling. SQL errors roll back all rows and propagate. The later - metadata touch is separate from the committed transaction. Cancellation - is not caught by the ordinary-exception rollback handler. + 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 (?, ?, ?, ?, ?, ?)" ) - await self.backend.connection.execute("BEGIN") - try: - for tag in tags: - await self.backend.connection.execute( + 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, - ), + [ + ( + tag.name, + tag.table, + tag.address, + tag.type, + tag.byte_order, + tag.word_order, + ) + for tag in tags + ], ) - await self.backend.connection.commit() - except Exception: - await self.backend.connection.rollback() - raise await self.backend.touch() @@ -723,12 +730,15 @@ async def tag_export(self, path: Path): async def tag_import(self, path: Path, replace: bool = False): """Validate PATH and merge tags, returning the imported count as text. - Reject existing names unless replace=True. TUI confirmation is prepared - separately by ModbusApp.prepare_tag_import; direct calls never prompt. - The file is reread here. Ordinary SQL failures roll back imported rows; - cancellation and project races are documented as open discrepancies. + 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. """ - tags = parse_tag_document(path) + 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( diff --git a/tests/test_tags.py b/tests/test_tags.py index 8ce60ba..a0e7746 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -1,9 +1,11 @@ """Typed tag persistence, conversion, I/O, and interchange tests.""" import asyncio +import sqlite3 import tempfile import unittest from pathlib import Path +from unittest.mock import AsyncMock, patch from ctui import CommandError, ConfirmationRequired, PathCompleter @@ -315,6 +317,121 @@ async def decline(message): result = await self.app.dispatch(f"tag import {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("tag 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", + "tag delete timer", + "tag 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 tag import") + return True + + await self.app.dispatch(f"tag imp {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("tag 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("tag 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("tag 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"tag import {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("tag 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("tag 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 ("tag import", "tag export"): From 4cb75fd6b78cb2fbc2980113da6fed832d72fd90 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Fri, 2 Oct 2026 22:35:38 -0700 Subject: [PATCH 20/38] feat: add modbus servers, device emulation and proxying --- AGENTS.md | 6 + CHANGELOG.md | 6 + README.md | 21 +- docs/DECISIONS.md | 33 ++ docs/SERVER.md | 279 +++++++++++++ docs/STATUS.md | 26 +- examples/device.toml | 54 +++ examples/device_logic.py | 14 + src/ctmodbus/app.py | 120 +++++- src/ctmodbus/data_state.py | 157 ++++++++ src/ctmodbus/operations.py | 8 +- src/ctmodbus/server.py | 669 ++++++++++++++++++++++++++++++++ src/ctmodbus/server_commands.py | 651 +++++++++++++++++++++++++++++++ src/ctmodbus/server_config.py | 307 +++++++++++++++ src/ctmodbus/simulation.py | 249 ++++++++++++ tests/smoke_test.py | 16 +- tests/test_server.py | 613 +++++++++++++++++++++++++++++ 17 files changed, 3206 insertions(+), 23 deletions(-) create mode 100644 docs/SERVER.md create mode 100644 examples/device.toml create mode 100644 examples/device_logic.py create mode 100644 src/ctmodbus/data_state.py create mode 100644 src/ctmodbus/server.py create mode 100644 src/ctmodbus/server_commands.py create mode 100644 src/ctmodbus/server_config.py create mode 100644 src/ctmodbus/simulation.py create mode 100644 tests/test_server.py diff --git a/AGENTS.md b/AGENTS.md index 670d239..3effda5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,6 +65,12 @@ Publishing requires release approval; pushing a version tag triggers publication - `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. +- [docs/SERVER.md](docs/SERVER.md): server, proxy, configuration and hook contracts. - `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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 110e783..9ded126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [1.0.0rc1] - Unreleased +- Add independent TCP/UDP/TLS/RTU/ASCII serving, project-scoped TOML definitions, + sparse table defaults, typed static/random/sequence rules, transactional Python + hooks, validation, export and runtime reset commands. +- Add serialized cross-transport proxying and separate client/downstream + observation views, retaining write uncertainty and decoded operation records. + - Import the exact confirmed tag data, guard project/tag changes during import, and apply tag merges in an isolated all-or-none transaction. diff --git a/README.md b/README.md index 004d279..d8ddbb2 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,7 @@ need hardware smoke testing. This is a breaking migration from 0.x. Legacy command spellings, space-separated multi-write values, host:port syntax, and old storage formats are not supported. -Polling, simulation, proxies, raw/fuzzy requests, +Polling, device cloning, MITM rules, raw/fuzzy requests, tunneling, and historian integration remain deferred. See [MIGRATION.md](MIGRATION.md) for the 0.x transition and @@ -266,3 +266,22 @@ in module, class, and callable docstrings. Copyright Justin Searle. Licensed under the GNU General Public License, version 3 or later. See [LICENSE](LICENSE). +## Serving and proxying + +ctmodbus can serve TCP, UDP, TLS, RTU and ASCII while maintaining an independent +client connection. Configure sparse raw ranges or typed tags with static, +random and sequence behavior, plus optional Python hooks. Server definitions +can be assembled in CTUI or imported/exported as TOML. + +```text +serve data import examples/device.toml +serve tcp 127.0.0.1 --port 5020 +serve data show +serve stop +connect tcp 127.0.0.1 --port 502 +connect data show +``` + +`proxy enable` routes incoming server requests through the connected client; +`proxy disable` restores local behavior. See [server and proxy usage](docs/SERVER.md) +for all commands, foreground CLI serving, hooks, defaults and evidence semantics. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 5adaa67..29d4e82 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -239,3 +239,36 @@ 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. + +The detailed command/config/hook contract is in [SERVER.md](SERVER.md). diff --git a/docs/SERVER.md b/docs/SERVER.md new file mode 100644 index 0000000..9970041 --- /dev/null +++ b/docs/SERVER.md @@ -0,0 +1,279 @@ +# Serving and proxying Modbus + +`serve` creates one application-owned listener, independent of the single client +opened by `connect`. Configure the server before starting it. The TUI remains +interactive; stopping the server keeps the client connection open. Exiting shuts +both down. Project changes and tag/definition edits require a stopped server. +Definitions are saved per project through ctui configs; live observations, tasks, +values and Python functions are not persisted. Whole-project snapshots include +the definition and tags, but not external Python or certificate files. + +## Start and stop + +```text +serve data import examples/device.toml +serve data validate +serve tcp 127.0.0.1 --port 5020 +serve status +serve data show +serve stop +``` + +Other listeners share the same local definition: + +```text +serve udp 127.0.0.1 --port 5020 +serve tls 127.0.0.1 --port 8020 --cert-file server.pem --key-file server.key +serve rtu /dev/ttyUSB0 --baudrate 9600 +serve ascii /dev/ttyUSB0 --baudrate 9600 +``` + +TCP/UDP default to loopback port 5020; TLS defaults to loopback port 8020. +Explicit ports 502/802 work when the operating system permits binding them. +Serial defaults match `connect`: 9600 baud, 8 data bits, no parity, one stop bit. +Serial serving responds as a device, not a passive tap. The client and listener +must use different serial ports. Unit IDs 1–247 are supported, defaulting to 1; +serial traffic for other units (including broadcasts) is ignored. +TLS uses native PyModbus TLS framing. Certificate and unencrypted key files are +required. `--ca-file` additionally requires trusted client certificates. + +The shared dispatch path also supports CLI command sequences. Use explicit +`--foreground` to keep a listener alive in CLI mode: + +```bash +uv run --with-editable ../ctui ctmodbus \ + -c "serve data import examples/device.toml" \ + -c "serve tcp 127.0.0.1 --port 5020 --foreground" +``` + +Without foreground mode, a CLI invocation exits after its commands and closes +its listener. In a TUI, use the normal background form. Foreground commands +wait until stopped or interrupted; do not place later commands behind them in +a command file. + +## Address maps and values + +Without a definition, all four tables implement protocol addresses 0–65535, +initialized to false or zero. Defaults are sparse: no giant initial value array. +This is permitted by Modbus Application Protocol V1.1b3 sections 4.3/4.4. A +request still obeys per-function quantity limits, and cannot cross address 65535. + +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 +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. + +## Assemble a definition in CTUI + +```text +serve data clear --confirm +serve data unit 1 +serve data seed 123 +serve data identity --vendor ControlThings --product "Tank and pump simulator" --revision 1.0 +serve data table coils --unmapped illegal +serve data table discrete_inputs --unmapped illegal +serve data table input_registers --unmapped illegal +serve data table holding_registers --unmapped illegal + +tag create pump_enabled coil 0 bool +tag create pump_running discrete_input 0 bool +tag create tank_level input_register 10 float32 +serve data set tag pump_enabled false +serve data set tag pump_running false +serve data set tag tank_level 100.0 + +serve data set holding_registers 100-199 42 +serve data random holding_registers 7 --min 0 --max 100 +serve data sequence tag tank_level 100,75,50,25 --advance time --interval 1.0 +serve data sequence holding_registers 8 10,20,30 --advance read --repeat=false + +serve hook write examples/device_logic.py on_write +serve hook tick examples/device_logic.py on_tick --interval 1.0 +serve data validate +serve data export device.toml +``` + +`set`, `random`, and `sequence` replace a matching tag/exact raw-range rule. +`serve data remove tag NAME` removes a server tag rule without deleting the +project tag. `serve data remove TABLE RANGE` removes exact configured raw ranges; +the table fallback still applies. `serve 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. + +`serve data 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. + +`serve data validate` checks definitions, shared project tags and referenced +hook callables without opening a listener. It does not prove hook behavior or +port availability. `serve data reset --confirm` restores running local values, +random state and sequence clocks from the initial definition. Reset is disabled +in proxy mode. Raw data and configured initial values remain distinct. + +## Optional Python hooks + +The [pump example](../examples/device.toml) references a companion +[Python module](../examples/device_logic.py): + +```toml +[hooks] +module = "device_logic.py" +on_read = "on_read" +on_write = "on_write" +on_tick = "on_tick" +tick_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. + +Supported signatures: + +```python +def on_read(device, tag_names): + # Once per local read, including partial tags. Return value is ignored. + device.set("temperature_f", device.get("temperature_c") * 1.8 + 32) + +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 `serve 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. + +## Proxying and evidence views + +```text +connect rtu /dev/ttyUSB0 --baudrate 9600 --retries 0 +serve tcp 127.0.0.1 --port 5020 +proxy enable +proxy status +connect data show +serve data show +proxy disable +serve stop +``` + +Proxying requires both endpoints. 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 enable requires `--retries 0`. Closing the client +cancels/drains active exchanges; proxy mode remains enabled and fails clearly +until reconnection or `proxy disable`. Obvious loops back to the listener are +rejected. Proxy disable routes new requests locally; already-routed exchanges +finish using their original mode. Saved emulator configuration is preserved. + +`connect data show` works without a server, accumulating successful console +reads and attempted/acknowledged/uncertain writes. It includes the last observed +device identification. `serve data show` shows configured initial rules, current +local static tags and last downstream observations. Proxy upstream values are +recorded before the response extension point; downstream values afterwards. +The two views are independent. Neither show 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. Future MITM rules will use a separate TOML file; +only request/response extension seams exist today. diff --git a/docs/STATUS.md b/docs/STATUS.md index ab89cb7..8b14aa0 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,7 +1,7 @@ # Project status -Last reconciled: 2026-10-02, `dev-v1.0.0` based on `920daa0`, with local -prepared tag-import and transaction-safety changes. +Last reconciled: 2026-10-03, `dev-v1.0.0` based on `2c9afc7`, with local +server/emulator, plain-proxy and runtime data-view changes. ## Current state @@ -26,6 +26,12 @@ prepared tag-import and transaction-safety changes. spellings are not retained as aliases. Both path arguments use the shared ctui path completer from the adjacent development checkout. +- 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 [SERVER.md](SERVER.md), [D13](DECISIONS.md#d13--independent-servers-sparse-emulation-proxy-routing-and-evidence-views), + and the pump/tank example under `examples/`. MITM rules remain deferred. + ## Next steps and validation gaps 1. Multi-tag serialization/partial output and tag-import confirmation, atomic @@ -44,12 +50,26 @@ prepared tag-import and transaction-safety changes. 4. Obtain release approval before publication. Before stable 1.0, adopt tested stable ctui and repeat release gates (D08). -Polling, device cloning/simulation, proxies, raw/fuzzy requests, tunneling, and +Polling, device cloning, MITM rules, raw/fuzzy requests, tunneling, and historian integration remain deferred. No implementation blocker is established; release readiness lacks final-revision and manual/remote validation. ## Validation evidence +- 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, diff --git a/examples/device.toml b/examples/device.toml new file mode 100644 index 0000000..6bf85a3 --- /dev/null +++ b/examples/device.toml @@ -0,0 +1,54 @@ +# ctmodbus pump/tank server configuration. +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_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/src/ctmodbus/app.py b/src/ctmodbus/app.py index fc8678c..62699fc 100644 --- a/src/ctmodbus/app.py +++ b/src/ctmodbus/app.py @@ -21,8 +21,11 @@ ) from ctmodbus.connection import Connection, ConnectionSettings, create_client +from ctmodbus.data_state import DataState from ctmodbus.discovery import complete_serial, suggestions from ctmodbus.operations import ModbusCommandMixin +from ctmodbus.server import Server +from ctmodbus.server_commands import CONFIG_NAME, ServerCommandMixin from ctmodbus.tags import TagCommandMixin, TagStore, parse_tag_document NETWORK_ARGUMENTS = { @@ -56,7 +59,9 @@ async def complete_profiles(context): return list(await context.app.configs.list()) -class ModbusApp(TagCommandMixin, ModbusCommandMixin, CtuiApp): +class ModbusApp( # pylint: disable=too-many-public-methods + ServerCommandMixin, 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 @@ -83,6 +88,10 @@ def __init__(self, *, client_factory=create_client, **kwargs): super().__init__(**kwargs) self.connection = Connection(client_factory) self.tags = TagStore(self.backend) + self.server = Server(self) + 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 @@ -118,7 +127,10 @@ def connection_status(self): if self.backend and self.backend.current else "unopened" ) - return f"Project: {project} | {state}{self._progress}" + 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. @@ -129,6 +141,16 @@ def update_progress(self, completed, total): 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. @@ -148,13 +170,7 @@ async def prepare_tag_import(self, text, kwargs): + ". Overwrite them? Re-run with --replace to overwrite " "without prompting." ) - callback = kwargs.get("confirm_callback") - if callback is None: - raise ConfirmationRequired(message) - approved = callback(message) - if asyncio.iscoroutine(approved): - approved = await approved - if not approved: + if not await self.confirm_replacement(message, kwargs): return CommandResult.rejected() replace_existing = True self._prepared_tag_imports[asyncio.current_task()] = ( @@ -191,6 +207,28 @@ async def dispatch( # pylint: disable=too-many-branches,too-many-statements device_command = item.name.startswith( ("connect ", "read ", "write ", "profile ") ) + server_import = item.name == "serve data import" + server_start = item.name in { + "serve tcp", + "serve udp", + "serve tls", + "serve rtu", + "serve ascii", + } + server_edit = ( + ( + item.name.startswith("serve data ") + and item.name + not in { + "serve data show", + "serve data export", + "serve data validate", + "serve data reset", + } + ) + or item.name.startswith("serve hook ") + or server_start + ) task = asyncio.current_task() if self._stopping: raise CommandError("Application is stopping") @@ -201,7 +239,27 @@ async def dispatch( # pylint: disable=too-many-branches,too-many-statements "tag delete", "tag import", } - if self._tag_import_task is not None and (project_change or tag_mutation): + 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") @@ -239,11 +297,17 @@ async def dispatch( # pylint: disable=too-many-branches,too-many-statements 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 importing: self._tag_import_task = task try: + 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: @@ -253,6 +317,10 @@ async def dispatch( # pylint: disable=too-many-branches,too-many-statements # 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() if item.name == "project reset" and "all" in tokens and result.accepted: await self.tags.clear() warnings = self._record_warnings.get(task) @@ -274,6 +342,9 @@ async def dispatch( # pylint: disable=too-many-branches,too-many-statements ) from error raise finally: + if server_edit: + self._server_edit_task = None + self._prepared_server_imports.pop(task, None) if tag_mutation: self._tag_mutations.discard(task) if importing: @@ -294,6 +365,7 @@ async def record_operation(self, direction, decoded): 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: @@ -331,7 +403,9 @@ async def close_connection(self): async with self._close_lock: self._closing = True try: - pending = self._device_dispatches - {asyncio.current_task()} + pending = (self._device_dispatches | self.connection.tasks) - { + asyncio.current_task() + } for task in pending: task.cancel() try: @@ -353,10 +427,17 @@ async def on_start(self): async def on_stop(self): """Reject new dispatches and await close_connection before backend closure.""" self._stopping = True - importing = self._tag_import_task - if importing is not None and importing is not asyncio.current_task(): - importing.cancel() - await asyncio.gather(importing, return_exceptions=True) + pending = self._tag_mutations | { + task + for task in (self._tag_import_task, self._server_edit_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() @command(name="connect") @@ -371,7 +452,14 @@ async def open_connection(self, settings): 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") 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( @@ -517,6 +605,8 @@ async def profile_save(self, name: str): storage errors propagate. Profiles contain certificate paths, not keys or certificate contents, and depend on those paths on the next machine. """ + if name == CONFIG_NAME: + raise CommandError("That name is reserved for server configuration") if self.connection.settings is None: raise CommandError("Connect before saving a profile") await self.configs.save(name, self.connection.settings.as_dict()) 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/operations.py b/src/ctmodbus/operations.py index 6c2480c..42c1266 100644 --- a/src/ctmodbus/operations.py +++ b/src/ctmodbus/operations.py @@ -347,11 +347,15 @@ async def write_values(self, kind, address, values): if sent else "No write was sent." ) - await self.record_operation("error", {**request, "error": message}) + 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}) + await self.record_operation( + "error", {**request, "error": message, "sent": sent} + ) raise CommandError(message) from error return CommandResult.append( f"{timestamp()} Write acknowledged: {kind}\n" diff --git a/src/ctmodbus/server.py b/src/ctmodbus/server.py new file mode 100644 index 0000000..21fc2e8 --- /dev/null +++ b/src/ctmodbus/server.py @@ -0,0 +1,669 @@ +"""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.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: + 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.""" + try: + if ( + self.server.owner.serial_device + and request.dev_id != self.server.owner.config["unit"] + ): + return + response = await self.server.owner.handle(request) + 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_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("serve 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 + + 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 + ): + """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 serve 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.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_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}" + 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) + + async def handle(self, request): + """Capture mode before queuing and serialize complete downstream exchanges.""" + proxy = self.proxy + async with self.request_lock: + return await self.route(request, proxy) + + async def route(self, request, proxy): + # 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, + "unit": request.dev_id, + "address": request.address, + "count": count, + "mode": "proxy" if proxy else "local", + }, + ) + response = ( + await self.forward(request) 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, + "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): # pylint: disable=too-many-branches + """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 + 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" + ), + ) + return await self.after_response(copy.deepcopy(response)) + except asyncio.CancelledError: + 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}" + 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..bbfa9e9 --- /dev/null +++ b/src/ctmodbus/server_commands.py @@ -0,0 +1,651 @@ +"""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 = "ctmodbus-server" +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",)), + "foreground": Argument(flags=("--foreground",)), +} +SERIAL_ARGS = { + "device": Argument(help="Local serial device path"), + **{ + name: Argument(flags=("--" + name.replace("_", "-"),)) + for name in ("baudrate", "bytesize", "parity", "stopbits", "foreground") + }, +} + + +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="serve data import", + arguments={**PATH_ARG, "replace": Argument(flags=("--replace",))}, + ) + async def serve_data_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="serve data export", arguments=PATH_ARG) + async def serve_data_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="serve data clear", arguments={"confirm": Argument(flags=("--confirm",))} + ) + async def serve_data_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="serve data unit", + arguments={"unit": Argument(help="Server unit ID, 1–247")}, + ) + async def serve_data_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="serve data seed", + arguments={"seed": Argument(help="Reproducible random seed")}, + ) + async def serve_data_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="serve data identity", + arguments={ + name: Argument(flags=("--" + name,)) + for name in ("vendor", "product", "revision") + }, + ) + async def serve_data_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="serve data table", + arguments={ + "table": Argument(help="Modbus table"), + "unmapped": Argument(flags=("--unmapped",)), + "default": Argument(flags=("--default",)), + }, + ) + async def serve_data_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="serve data set", + arguments={**TARGET_ARGS, "value": Argument(help="Initial scalar value")}, + ) + async def serve_data_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="serve data random", + arguments={ + **TARGET_ARGS, + "minimum": Argument(flags=("--min",)), + "maximum": Argument(flags=("--max",)), + }, + ) + async def serve_data_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="serve data 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 serve_data_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_seconds"] = interval + await self.save_server_definition(config) + return f"Configured {name}: {function}." + + @command( + name="serve hook read", + arguments={ + "module": Argument(help="Companion Python file", completer=PathCompleter()), + "function": Argument(help="Read hook function name"), + }, + ) + async def serve_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="serve hook write", + arguments={ + "module": Argument(help="Companion Python file", completer=PathCompleter()), + "function": Argument(help="Write hook function name"), + }, + ) + async def serve_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="serve hook tick", + arguments={ + "module": Argument(help="Companion Python file", completer=PathCompleter()), + "function": Argument(help="Tick hook function name"), + "interval": Argument(flags=("--interval",)), + }, + ) + async def serve_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="serve hook clear") + async def serve_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="serve data remove", arguments=TARGET_ARGS) + async def serve_data_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="serve data validate") + async def serve_data_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="serve data show") + async def serve_data_show(self): + """Display committed state without observing a hook's pending transaction.""" + async with self.server.request_lock: + return await self.show_server_data() + + async def show_server_data(self): + """Show definition, local tag state and downstream observations without + device I/O. + """ + config = await self.server_definition() + lines = [ + "Server definition (initial values / rules):", + server_config.dumps(config), + ] + 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) + + @command(name="connect data show") + async def connect_data_show(self): + """Show last read/write evidence, without issuing requests or treating + writes as readback. + """ + heading = ( + "Connected" + if self.connection.connected + else "Disconnected; last session evidence retained" + ) + return heading + "\n" + self.client_state.show(await self.tags.list()) + + async def start_server(self, settings, foreground=False, **tls): + """Start in background for TUI, or wait until stopped in explicit + foreground mode. + """ + 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, **tls) + except (OSError, ValueError, RuntimeError) as error: + raise CommandError(f"Server start failed: {error}") from error + if foreground: + try: + await self.server.stop_event.wait() + finally: + await self.server.stop() + return CommandResult.append(f"Server {self.server.label}") + + @command(name="serve tcp", arguments=NETWORK_ARGS) + async def serve_tcp( + self, host: str = "127.0.0.1", port: int = 5020, foreground: bool = False + ): + """Serve Modbus TCP; --foreground keeps command-line mode alive.""" + return await self.start_server( + ConnectionSettings("tcp", host, port=port), foreground + ) + + @command(name="serve udp", arguments=NETWORK_ARGS) + async def serve_udp( + self, host: str = "127.0.0.1", port: int = 5020, foreground: bool = False + ): + """Serve Modbus UDP from the local definition.""" + return await self.start_server( + ConnectionSettings("udp", host, port=port), foreground + ) + + @command( + name="serve tls", + arguments={ + **NETWORK_ARGS, + **{ + name: Argument(flags=("--" + name.replace("_", "-"),)) + for name in ("cert_file", "key_file", "ca_file") + }, + }, + ) + async def serve_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, + foreground: bool = False, + ): + """Serve native Modbus TLS; optional CA roots require client certificates.""" + return await self.start_server( + ConnectionSettings("tls", host, port=port), + foreground, + cert_file=cert_file, + key_file=key_file, + ca_file=ca_file, + ) + + @command(name="serve rtu", arguments=SERIAL_ARGS) + async def serve_rtu( + self, + device: str, + baudrate: int = 9600, + bytesize: int = 8, + parity: Literal["N", "E", "O"] = "N", + stopbits: int = 1, + foreground: 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, + ), + foreground, + ) + + @command(name="serve ascii", arguments=SERIAL_ARGS) + async def serve_ascii( + self, + device: str, + baudrate: int = 9600, + bytesize: int = 8, + parity: Literal["N", "E", "O"] = "N", + stopbits: int = 1, + foreground: 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, + ), + foreground, + ) + + @command(name="serve stop") + async def serve_stop(self): + """Stop the listener and drain local/proxy work; keep the client connection.""" + await self.server.stop() + return "Server stopped." + + @command(name="serve status") + async def serve_status(self): + """Show listener, proxy mode and the last hook/transport/storage error.""" + mode = "proxy" if self.server.proxy else "local" + return ( + f"Server: {self.server.label}\nMode: {mode}\n" + f"Last error: {self.server.last_error or 'none'}" + ) + + @command( + name="serve data reset", arguments={"confirm": Argument(flags=("--confirm",))} + ) + async def serve_data_reset(self, confirm: bool = False): + """Reset local runtime values, random seed and sequences; leave proxy + evidence alone. + """ + 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) + self.server.state.rows.clear() + return "Local simulation reset." + + @command(name="proxy enable") + async def proxy_enable(self): + """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 + return "Proxy enabled. New requests use the upstream device." + + @command(name="proxy disable") + async def proxy_disable(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}" diff --git a/src/ctmodbus/server_config.py b/src/ctmodbus/server_config.py new file mode 100644 index 0000000..41845ac --- /dev/null +++ b/src/ctmodbus/server_config.py @@ -0,0 +1,307 @@ +"""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, encode_tag_value + +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 { + "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()) + unknown = set(rule) - allowed + if mode not in MODES or unknown: + raise CommandError(f"Invalid {mode!r} rule fields: {sorted(unknown)}") + 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 validate(config): # pylint: disable=too-many-branches + """Return normalized settings; reject unknown fields, spans, and rule overlaps.""" + try: + if not isinstance(config, dict) or set(config) - { + "version", + "unit", + "identity", + "tables", + "tags", + "hooks", + "seed", + }: + raise CommandError("Unknown server configuration fields") + if ( + type(config.get("version", 1)) is not int + or config.get("version", 1) != 1 + or type(config.get("unit", 1)) is not int + or not 1 <= config.get("unit", 1) <= 247 + ): + raise CommandError("Require version 1 and unit ID 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") + 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 + if set(result["tables"]) - set(TABLES): + raise CommandError("Unknown Modbus 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) + spans = [] + for rule in settings["ranges"]: + 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(): + tag = tag_from(name, item) + rule = { + k: v + for k, v in item.items() + if k not in {"table", "address", "type", "byte_order", "word_order"} + } + 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"] + if set(hooks) - {"module", "on_read", "on_write", "on_tick", "tick_seconds"}: + raise CommandError("Unknown emulator hook") + if any( + not isinstance(v, str) or not v + for k, v in hooks.items() + if k != "tick_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_seconds", 1) + if ( + type(interval) not in (int, float) + or not math.isfinite(interval) + or interval <= 0 + ): + raise CommandError("tick_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 data: {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/tests/smoke_test.py b/tests/smoke_test.py index 7730dcd..80dfefb 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -1,6 +1,7 @@ """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 @@ -20,9 +21,20 @@ def main(): result = subprocess.run( ["ctmodbus", "--help"], check=True, capture_output=True, text=True ) - assert "read holding_registers" in result.stdout - assert "profile connect" in result.stdout + assert "read" in result.stdout + assert "profile" in result.stdout + assert "serve" in result.stdout + assert "proxy" in result.stdout with tempfile.TemporaryDirectory() as directory: + groups = subprocess.run( + ["ctmodbus", "-c", "help read", "-c", "help profile", "-c", "help serve"], + check=True, + capture_output=True, + text=True, + env={**os.environ, "XDG_DATA_HOME": directory}, + ) + for command in ("holding_registers", "connect", "tcp", "rtu", "data"): + assert command in groups.stdout app = ModbusApp(data_dir=directory) status = asyncio.run( app.run_cli(["-c", "project", "-c", "configs show tcp-local"]) diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..737d660 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,613 @@ +"""Server configuration, sparse behavior, real listeners and proxy regressions.""" + +import asyncio +import copy +import os +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( + { + "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({"tables": {"holding_registers": {"ranges": [rule]}}}) + with self.assertRaises(CommandError): + validate( + { + "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() + 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"serve {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("serve stop") + + async def test_ui_configuration_export_and_import_confirmation(self): + for text in ( + "serve data clear --confirm", + "tag create level input_register 10 float32", + "serve data set tag level 12.5", + "serve data set holding_registers 100-199 42", + "serve data sequence holding_registers 7 10,20,30 --advance read", + "serve data table coils --unmapped default --default true", + ): + await self.app.dispatch(text) + path = Path(self.folder.name) / "device.toml" + await self.app.dispatch(f"serve data export {path}") + original = await self.app.server_definition() + await self.app.dispatch("serve data clear --confirm") + await self.app.dispatch(f"serve data import {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"serve data import {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"serve data import {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("serve data clear --confirm") + await self.app.dispatch( + "serve data 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", + "serve data clear --confirm", + "tag create x coil 0 bool", + ): + with self.assertRaises(CommandError): + await self.app.dispatch(text) + self.assertIn("7", (await self.app.dispatch("serve data show")).output) + await self.app.dispatch("serve data reset --confirm") + 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"connect udp 127.0.0.1 --port {port}") + await self.app.dispatch("read holding_registers 0") + self.assertIn("17", (await self.app.dispatch("connect data show")).output) + await self.app.dispatch("serve data clear --confirm") + await self.app.dispatch("serve data unit 7") + client = await self.client() + await self.app.dispatch("proxy enable") + 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 ("serve data show", "connect data show"): + self.assertIn("55", (await self.app.dispatch(text)).output) + await self.app.dispatch("proxy disable") + self.assertEqual( + ( + await client.read_holding_registers(0, count=1, device_id=7) + ).exception_code, + 2, + ) + await self.app.dispatch("serve stop") + 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"connect tcp 127.0.0.1 --port {port}") + client = await self.client() + await self.app.dispatch("proxy enable") + + 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 + ) + await self.app.dispatch("close") + self.assertEqual( + ( + await client.read_holding_registers(0, count=1, device_id=1) + ).exception_code, + 11, + ) + 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("serve data clear --confirm") + await self.app.dispatch("tag create enable coil 0 bool") + await self.app.dispatch("tag create running discrete_input 0 bool") + await self.app.dispatch("serve data set tag enable false") + await self.app.dispatch("serve data set tag running false") + await self.app.dispatch(f"serve hook write {path} on_write") + await self.app.dispatch(f"serve hook tick {path} on_tick --interval 0.02") + await self.app.dispatch("serve data 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("serve 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"serve data export {path_export}") + self.assertEqual(load(path_export)["hooks"]["module"], str(path)) + + 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_foreground_cleanup(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"serve tcp 127.0.0.1 --port {port}") + self.assertIsNone(self.app.server.listener) + task = asyncio.create_task( + self.app.dispatch(f"serve tcp 127.0.0.1 --port {port} --foreground") + ) + + async def started(): + while self.app.server.label == "Stopped": + await asyncio.sleep(0.01) + + await asyncio.wait_for(started(), 1) + self.assertFalse(task.done()) + await self.app.dispatch("serve stop") + await asyncio.wait_for(task, 1) + 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): + 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", + ], + check=True, + capture_output=True, + ) + port = free_port() + await self.app.dispatch( + f"serve tls 127.0.0.1 --port {port} --cert-file {cert} --key-file {key}" + ) + context = ssl.create_default_context(cafile=str(cert)) + client = AsyncModbusTlsClient( + "localhost", port=port, sslctx=context, timeout=0.5, 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("serve stop") + await self.app.dispatch( + f"serve tls 127.0.0.1 --port {port} --cert-file {cert} --key-file {key} --ca-file {cert}" + ) + context.load_cert_chain(str(cert), str(key)) + client = AsyncModbusTlsClient( + "localhost", port=port, sslctx=context, timeout=0.5, 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"serve {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("serve stop") + upstream = make_server(transport, os.ttyname(a)) + await upstream.serve_forever(background=True) + try: + await self.app.dispatch( + f"connect {transport} {os.ttyname(b)} --timeout 1" + ) + downstream = await self.client() + await self.app.dispatch("proxy enable") + self.assertEqual( + ( + await downstream.read_holding_registers( + 0, count=1, device_id=1 + ) + ).registers, + [17], + ) + await self.app.dispatch("serve stop") + await self.app.dispatch("close") + 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"connect 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("close") + await self.app.dispatch(f"connect tcp 127.0.0.1 --port {port}") + self.assertEqual(self.app.client_state.rows, {}) + await self.app.dispatch("close") + 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() From 759b8f7e8672a2356b395780079e6ce462c0fb25 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Mon, 5 Oct 2026 12:29:47 -0600 Subject: [PATCH 21/38] docs: clarify editable ctui development setup --- README.md | 21 +++++++++++++++------ docs/STATUS.md | 19 +++++++++++++++++-- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index d8ddbb2..c487dfc 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,17 @@ For development: ```bash uv sync --locked -uv run ctmodbus +uv pip install --python .venv/bin/python --editable ../ctui +uv run --with-editable ../ctui ctmodbus ``` +Until release preparation, use the adjacent `../ctui` development checkout. +The editable install makes it available to IDEs using `.venv/bin/python`; +repeat that install after `uv sync`, which can restore the locked package. +Keep `--with-editable ../ctui` on development `uv run` commands. This temporary +setup leaves the declared dependency and lockfile unchanged; release validation +must use the intended published ctui version without the override. + After a release candidate has been published, install it with `uv tool install 'ctmodbus==1.0.0rc1'` or `python -m pip install 'ctmodbus==1.0.0rc1'`. @@ -234,17 +242,18 @@ successful-command behavior; protocol errors are retained as operation records. ## Development and validation ```bash -uv run 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 run --with-editable ../ctui python -m unittest discover -s tests -v +uv run --with-editable ../ctui black --check src tests +uv run --with-editable ../ctui isort --check-only src tests +uv run --with-editable ../ctui pylint src/ctmodbus uv build ``` Tests use isolated temporary projects, injected clients, actual local TCP/UDP servers, and a terminal runtime with injected input/output. POSIX systems also exercise RTU/ASCII over bridged pseudo-terminals; Windows skips those PTY tests. -For a local manual TCP fixture, run `uv run python tests/server.py tcp --port 5020`. +For a local manual TCP fixture, run +`uv run --with-editable ../ctui python tests/server.py tcp --port 5020`. The same fixture supports `udp`, `rtu`, and `ascii`; serial fixtures take `--target DEVICE`. TLS integration tests use temporary certificates generated with `openssl` and are skipped if that executable is unavailable. Physical serial adapters still diff --git a/docs/STATUS.md b/docs/STATUS.md index 8b14aa0..9d6611e 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,7 +1,7 @@ # Project status -Last reconciled: 2026-10-03, `dev-v1.0.0` based on `2c9afc7`, with local -server/emulator, plain-proxy and runtime data-view changes. +Last reconciled: 2026-10-05, `dev-v1.0.0` at `4cb75fd`, with development +environment setup documentation changes. ## Current state @@ -14,6 +14,10 @@ server/emulator, plain-proxy and runtime data-view changes. checkout through `uv run --with-editable ../ctui`. This temporary override leaves dependency metadata unchanged. Release validation must use the chosen published PyPI ctui version without the override. +- IDEs using `.venv/bin/python` need the adjacent checkout installed directly + with `uv pip install --python .venv/bin/python --editable ../ctui` after + `uv sync`. The run-time override alone uses a separate environment. See the + [development setup](../README.md#installation). - The owner reported RC1 unpublished on September 18. Remote refs, CI, and publication state have not been refreshed; CHANGELOG remains Unreleased. - The September 21 audit updates global documentation ownership rules, expands @@ -41,6 +45,8 @@ server/emulator, plain-proxy and runtime data-view changes. ctui enhancements listed there remain proposals, not dependency commitments. 2. Exercise tag completion, collision confirmation, and typed output in a real terminal; verify USB metadata on physical serial ports. + Investigate the command-suite stall with the current adjacent ctui checkout + before treating it as validated against ctmodbus (see October 5 evidence). 3. Before `1.0.0rc1` release validation, select the published PyPI ctui version, update the dependency and lockfile if needed, and stop using the local editable override. Then run the [release checklist](../RELEASE_CHECKLIST.md) @@ -56,6 +62,15 @@ release readiness lacks final-revision and manual/remote validation. ## Validation evidence +- 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, From b8af8e4a679ca2bd51769d5e84a197c4107f6f85 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Tue, 6 Oct 2026 16:49:51 -0600 Subject: [PATCH 22/38] refactor: rename tag management command to tags --- CHANGELOG.md | 4 +-- MIGRATION.md | 2 +- README.md | 24 ++++++------- docs/DECISIONS.md | 10 ++++-- docs/SERVER.md | 6 ++-- docs/STATUS.md | 17 ++++++++-- src/ctmodbus/app.py | 10 +++--- src/ctmodbus/tags.py | 16 ++++----- tests/test_server.py | 8 ++--- tests/test_tags.py | 80 ++++++++++++++++++++++---------------------- 10 files changed, 97 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ded126..f2d8514 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,8 @@ - Show USB manufacturer and product metadata in serial-device completions. - Add project-scoped typed tags with Boolean, signed/unsigned integer, and floating-point decoding; configurable byte/word order; tagged reads/writes; - and atomic TOML import/export with path completion through `tag export` and - `tag import`. + and atomic TOML import/export with path completion through `tags export` and + `tags import`. - Add `connect tls` with verified server certificates, custom CA roots, optional client identity, explicit insecure mode, and persisted TLS connection settings. diff --git a/MIGRATION.md b/MIGRATION.md index 227cf35..ae9788d 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -9,7 +9,7 @@ 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. `tag create` uses singular table names. + 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. diff --git a/README.md b/README.md index c487dfc..e157b13 100644 --- a/README.md +++ b/README.md @@ -90,13 +90,13 @@ Tags use the active connection and unit; they do not store a host, unit, or connection profile. ```text -tag create pause_sw coil 0 bool -tag create state holding_register 0 uint8 -tag create counter holding_register 1 uint16 -tag create timer holding_register 2 int32 -tag create energy input_register 10 float64 --word-order big -tag list -tag show timer +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 @@ -104,10 +104,10 @@ write tag timer 0x8000_0000 write tag timer -- -2_000_000_000 write tag state 0b0111_0001 write tag pause_sw on -tag rename timer duration -tag delete duration -tag export my_tags -tag import my_tags.toml +tags rename timer duration +tags delete duration +tags export my_tags +tags import my_tags.toml ``` Types are `bool`, signed and unsigned 8-, 16-, 32-, and 64-bit integers, and @@ -129,7 +129,7 @@ occupies the selected byte and the unused byte is written as zero: big byte order places it in the low byte; little byte order places it in the high byte. Byte and word order do not apply to Boolean tags. -`tag create` uses the singular table names `coil`, `discrete_input`, +`tags create` uses the singular table names `coil`, `discrete_input`, `input_register`, and `holding_register`. Coils and discrete inputs support `bool`; input and holding registers support numeric types. Writes are limited to coils and holding registers. Overlapping tags are allowed and reported when diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 29d4e82..0d191a1 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -187,16 +187,22 @@ 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 `tag create` because it accepts one starting +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 `tag export` and `tag import`; both +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. diff --git a/docs/SERVER.md b/docs/SERVER.md index 9970041..0736ee3 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -143,9 +143,9 @@ serve data table discrete_inputs --unmapped illegal serve data table input_registers --unmapped illegal serve data table holding_registers --unmapped illegal -tag create pump_enabled coil 0 bool -tag create pump_running discrete_input 0 bool -tag create tank_level input_register 10 float32 +tags create pump_enabled coil 0 bool +tags create pump_running discrete_input 0 bool +tags create tank_level input_register 10 float32 serve data set tag pump_enabled false serve data set tag pump_running false serve data set tag tank_level 100.0 diff --git a/docs/STATUS.md b/docs/STATUS.md index 9d6611e..f192bf7 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,7 +1,7 @@ # Project status -Last reconciled: 2026-10-05, `dev-v1.0.0` at `4cb75fd`, with development -environment setup documentation changes. +Last reconciled: 2026-10-06, `dev-v1.0.0` at `759b8f7`, with local tag +management command naming changes. ## Current state @@ -26,9 +26,12 @@ environment setup documentation changes. - 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 - `tag export` and `tag import`; the unreleased `export tags` and `import tags` + `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 adjacent development checkout. + 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, @@ -62,6 +65,14 @@ release readiness lacks final-revision and manual/remote validation. ## Validation evidence +- 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; diff --git a/src/ctmodbus/app.py b/src/ctmodbus/app.py index 62699fc..87863bd 100644 --- a/src/ctmodbus/app.py +++ b/src/ctmodbus/app.py @@ -232,12 +232,12 @@ async def dispatch( # pylint: disable=too-many-branches,too-many-statements task = asyncio.current_task() if self._stopping: raise CommandError("Application is stopping") - importing = item.name == "tag import" + importing = item.name == "tags import" tag_mutation = item.name in { - "tag create", - "tag rename", - "tag delete", - "tag import", + "tags create", + "tags rename", + "tags delete", + "tags import", } if self._server_edit_task is not None and ( project_change or tag_mutation or server_edit diff --git a/src/ctmodbus/tags.py b/src/ctmodbus/tags.py index 492ddf9..eb0b6c5 100644 --- a/src/ctmodbus/tags.py +++ b/src/ctmodbus/tags.py @@ -67,7 +67,7 @@ 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 tag create takes singular + 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. """ @@ -505,7 +505,7 @@ class TagCommandMixin: unexpected file errors can propagate; no mixin-wide serialization is added. """ - @command(name="tag create", arguments=TAG_ORDER_ARGUMENTS) + @command(name="tags create", arguments=TAG_ORDER_ARGUMENTS) async def tag_create( self, name: str, @@ -561,7 +561,7 @@ async def tag_create( output += "\nWarning: overlaps tags: " + ", ".join(overlaps) return CommandResult.append(output) - @command(name="tag list") + @command(name="tags list") async def tag_list(self): """List tags in the active project.""" tags = await self.tags.list() @@ -583,7 +583,7 @@ async def tag_list(self): headers=("Name", "Table", "Address", "Count", "Type", "Byte", "Word"), ) - @command(name="tag show", arguments={"name": Argument(completer=complete_tags)}) + @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) @@ -591,13 +591,13 @@ async def tag_show(self, name: str): values["range"] = f"{tag.address}-{tag.stop - 1}" return "\n".join(f"{key}: {value}" for key, value in values.items()) - @command(name="tag rename", arguments={"name": Argument(completer=complete_tags)}) + @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="tag delete", arguments={"name": Argument(completer=complete_tags)}) + @command(name="tags delete", arguments={"name": Argument(completer=complete_tags)}) async def tag_delete(self, name: str): """Delete a tag from the active project.""" await self.tags.delete(name) @@ -691,7 +691,7 @@ async def write_tag(self, name: str, value: str): ) @command( - name="tag export", + name="tags export", arguments={ "path": Argument(help="Destination TOML file", completer=PathCompleter()) }, @@ -721,7 +721,7 @@ async def tag_export(self, path: Path): return f"Exported tags to {path}." @command( - name="tag import", + name="tags import", arguments={ "path": Argument(help="TOML tag file to import", completer=PathCompleter()), "replace": Argument(flags=("--replace",)), diff --git a/tests/test_server.py b/tests/test_server.py index 737d660..1a674ed 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -235,7 +235,7 @@ async def test_tcp_and_udp_boundaries_and_identity(self): async def test_ui_configuration_export_and_import_confirmation(self): for text in ( "serve data clear --confirm", - "tag create level input_register 10 float32", + "tags create level input_register 10 float32", "serve data set tag level 12.5", "serve data set holding_registers 100-199 42", "serve data sequence holding_registers 7 10,20,30 --advance read", @@ -289,7 +289,7 @@ async def test_sparse_map_dynamic_rules_and_lifecycle_guards(self): for text in ( "project create other", "serve data clear --confirm", - "tag create x coil 0 bool", + "tags create x coil 0 bool", ): with self.assertRaises(CommandError): await self.app.dispatch(text) @@ -385,8 +385,8 @@ async def test_hook_file_failure_rolls_back_and_tick_changes_values(self): "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("serve data clear --confirm") - await self.app.dispatch("tag create enable coil 0 bool") - await self.app.dispatch("tag create running discrete_input 0 bool") + 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("serve data set tag enable false") await self.app.dispatch("serve data set tag running false") await self.app.dispatch(f"serve hook write {path} on_write") diff --git a/tests/test_tags.py b/tests/test_tags.py index a0e7746..2b62dae 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -108,18 +108,18 @@ async def connect(self): async def test_create_list_rename_delete_and_project_scope(self): """Verify tag CRUD, project switching, and full-reset removal.""" - await self.app.dispatch("tag create timer holding_register 2 int32") - result = await self.app.dispatch("tag list") + 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("tag show timer") + result = await self.app.dispatch("tags show timer") self.assertIn("range: 2-3", result.output) - await self.app.dispatch("tag rename timer duration") + await self.app.dispatch("tags rename timer duration") self.assertEqual((await self.app.tags.get("duration")).address, 2) - await self.app.dispatch("tag delete duration") + await self.app.dispatch("tags delete duration") self.assertEqual(await self.app.tags.list(), []) - await self.app.dispatch("tag create local coil 0 bool") + 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") @@ -129,22 +129,22 @@ async def test_create_list_rename_delete_and_project_scope(self): async def test_overlap_warning_and_duplicate_rejection(self): """Permit overlaps but reject duplicate names and invalid creation syntax.""" - await self.app.dispatch("tag create first holding_register 0 uint32") - result = await self.app.dispatch("tag create second holding_register 1 uint16") + 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("tag create first holding_register 3 uint16") + await self.app.dispatch("tags create first holding_register 3 uint16") with self.assertRaisesRegex(CommandError, "do not accept"): - await self.app.dispatch("tag create bit coil 0 bool --byte-order little") + 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("tag create old coils 0 bool") + 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("tag create switch coil 0 bool") - await self.app.dispatch("tag create state holding_register 5 uint8") - await self.app.dispatch("tag create timer holding_register 6 int32") - await self.app.dispatch("tag create sensed discrete_input 4 bool") + 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") @@ -197,8 +197,8 @@ async def test_tag_reads_and_writes(self): 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("tag create first holding_register 1 uint16") - await self.app.dispatch("tag create second holding_register 2 uint16") + 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]) @@ -211,7 +211,7 @@ 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"tag create {name} holding_register {address} uint16" + f"tags create {name} holding_register {address} uint16" ) await self.connect() self.client.reply = lambda name, args: response( @@ -236,7 +236,7 @@ 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"tag create {name} holding_register {address} uint16" + f"tags create {name} holding_register {address} uint16" ) await self.connect() self.client.gate = asyncio.Event() @@ -253,7 +253,7 @@ 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"tag create {name} holding_register {address} uint16" + f"tags create {name} holding_register {address} uint16" ) await self.connect() second_started = asyncio.Event() @@ -290,18 +290,18 @@ async def test_read_all_requires_at_least_one_defined_tag(self): 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("tag create timer holding_register 2 int32") + await self.app.dispatch("tags create timer holding_register 2 int32") path = Path(self.directory.name) / "my_tags" - result = await self.app.dispatch(f"tag export {path}") + result = await self.app.dispatch(f"tags export {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("tag delete timer") - await self.app.dispatch(f"tag import {exported}") + await self.app.dispatch("tags delete timer") + await self.app.dispatch(f"tags import {exported}") self.assertEqual((await self.app.tags.get("timer")).count, 2) with self.assertRaisesRegex(ConfirmationRequired, "timer"): - await self.app.dispatch(f"tag import {exported}") + await self.app.dispatch(f"tags import {exported}") messages = [] async def decline(message): @@ -310,16 +310,16 @@ async def decline(message): return False result = await self.app.dispatch( - f"tag import {exported}", confirm_callback=decline + f"tags import {exported}", confirm_callback=decline ) self.assertFalse(result.accepted) self.assertIn("--replace", messages[0]) - result = await self.app.dispatch(f"tag import {exported} --replace") + result = await self.app.dispatch(f"tags import {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("tag create timer holding_register 2 uint16") + 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")]) @@ -330,18 +330,18 @@ async def approve(message): self.assertIn("timer", message) for command in ( "project create other", - "tag delete timer", - "tag import missing.toml", + "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 tag import") + await self.app.dispatch("help tags import") return True - await self.app.dispatch(f"tag imp {path}", confirm_callback=approve) + await self.app.dispatch(f"tags imp {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) @@ -361,11 +361,11 @@ async def paused_save(tag): await save(tag) with patch.object(self.app.tags, "save", paused_save): - task = asyncio.create_task(self.app.dispatch("tag create new coil 0 bool")) + 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("tag import missing.toml") + await self.app.dispatch("tags import missing.toml") with self.assertRaises(CommandError): await self.app.dispatch("project create other") finally: @@ -375,7 +375,7 @@ async def paused_save(tag): async def test_cancelled_import_confirmation_leaves_tags_unchanged(self): """Cancellation releases the project guard without applying any data.""" - await self.app.dispatch("tag create timer holding_register 2 uint16") + 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")]) @@ -388,7 +388,7 @@ async def approve(message): await asyncio.Event().wait() task = asyncio.create_task( - self.app.dispatch(f"tag import {path}", confirm_callback=approve) + self.app.dispatch(f"tags import {path}", confirm_callback=approve) ) await asyncio.wait_for(started.wait(), 1) task.cancel() @@ -396,11 +396,11 @@ async def approve(message): await task self.assertEqual((await self.app.tags.get("timer")).address, 2) self.assertIsNone(self.app._tag_import_task) - await self.app.dispatch("tag delete timer") + 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("tag create existing coil 0 bool") + 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")] @@ -434,7 +434,7 @@ async def wait_before_transaction(): def test_import_and_export_use_path_completion(self): """Expose ctui filesystem suggestions on both tag-file path arguments.""" - for name in ("tag import", "tag export"): + for name in ("tags import", "tags export"): with self.subTest(command=name): completer = self.app.commands[name].arguments["path"].completer self.assertIsInstance(completer, PathCompleter) @@ -450,5 +450,5 @@ async def test_import_is_validated_before_changes(self): encoding="utf-8", ) with self.assertRaisesRegex(CommandError, "Invalid tag 'bad'"): - await self.app.dispatch(f"tag import {path}") + await self.app.dispatch(f"tags import {path}") self.assertEqual(await self.app.tags.list(), []) From 236ef82ada57587c1e93329a3e1574434185d4cf Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Tue, 6 Oct 2026 18:55:19 -0600 Subject: [PATCH 23/38] feat: add fixed-cadence tag and raw polling --- CHANGELOG.md | 6 + README.md | 66 ++++++ docs/DECISIONS.md | 35 ++++ docs/STATUS.md | 44 +++- src/ctmodbus/app.py | 50 ++++- src/ctmodbus/polling.py | 438 ++++++++++++++++++++++++++++++++++++++++ tests/test_polling.py | 392 +++++++++++++++++++++++++++++++++++ tests/test_tui.py | 16 +- 8 files changed, 1040 insertions(+), 7 deletions(-) create mode 100644 src/ctmodbus/polling.py create mode 100644 tests/test_polling.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f2d8514..23365c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [1.0.0rc1] - Unreleased +- Add fixed-cadence `poll tags` and multi-table `poll raw` with configurable + intervals, count/duration limits, status and graceful stop. Append compact + aligned typed/raw rows with a `#` counter in terminal and browser sessions, + retain partial read evidence, + and skip busy ticks without overlapping or accumulating device requests. + - Add independent TCP/UDP/TLS/RTU/ASCII serving, project-scoped TOML definitions, sparse table defaults, typed static/random/sequence rules, transactional Python hooks, validation, export and runtime reset commands. diff --git a/README.md b/README.md index e157b13..3c9dca6 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,72 @@ Concurrent exports still assume sequential use. See the [documented concurrency limitations](docs/DECISIONS.md#d12--implementation-discrepancies-and-ctui-proposals) before embedding concurrent tag-management or project-switching commands. +## Polling + +Start one poll in the TUI or WUI; commands remain available while it runs. +`poll tags` snapshots all tags in name order, or takes an ordered comma-separated +list. `poll raw` takes one or more table options using the same inclusive address +syntax as `read`. Each address/range is a separate column; table options and +ranges retain their command-line order, including duplicates. + +```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 +``` + +The interval defaults to one second. Interval and duration are positive finite +seconds; count is a positive number of started cycles. Both limits work in the +TUI and WUI; when both are supplied, the first reached stops new cycles. Duration +limits cycle starts, allowing an active cycle to finish. In CLI `-c`/`-f` mode, +polling streams output and waits for completion before the next command; supply +limits for scripts. An unlimited CLI poll runs until interrupted. + +The first cycle starts immediately. Later cycles target a fixed monotonic-clock +schedule (`start + n × interval`), without adding response time to the interval. +Each cycle serializes all selected reads on the existing connection. Ticks while +that cycle or another device operation is busy are skipped, never queued or +replayed. Event-loop delays also skip obsolete ticks. Scheduling is best effort, +not a guarantee of exact wire timing. `poll status` reports started/completed +cycles, skipped ticks, failed columns, limits, targets, and last cycle duration. + +A header and one line per started cycle append to existing output: + +```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 +``` + +Column widths are set when polling starts from headers, tag type bounds, and raw +range lengths. The `#` counter and numeric tags align right; booleans and raw +sequences align left, with ` | ` between every column. Unlimited polls reserve +six counter characters; bounded polls reserve at least three. If any value or +counter outgrows its width, a wider header precedes that row; earlier rows retain +their original spacing. Floats use up to nine significant digits for float32 and +17 for float64, with scientific notation where appropriate and `.0` for integral +values displayed without an exponent. Raw operation records remain unchanged. + +Tags display decoded integers, floats, or booleans. Raw bits use contiguous +`0`/`1`; registers use four uppercase hex digits without `0x`, separated by +spaces. A failed tag displays `ERR`; a raw range retains confirmed chunks and +appends `ERR` if a later chunk fails. Other columns and cycles continue while +the connection remains usable. Existing operation records retain confirmed raw +values and detailed errors; previous values are never presented as fresh results. +All completed rows append, including unchanged values. + +Tag definitions cannot be edited during polling or target preparation. Stop the +poll before changing projects; polls never follow a replacement connection. +`poll stop` stops future ticks and waits for the active cycle without closing the +connection. `cancel`, `close`, connection loss, or application shutdown stop +polling; cancellation closes the transport and requires explicit reconnection. + ## Connection options and operation results All connections accept `--unit` (1–247), `--timeout` (seconds), and `--retries` diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 0d191a1..df9e590 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -278,3 +278,38 @@ uncertainty. Observations reset on fresh sessions and are not persisted. MITM request/response seams exist, but rules and separate MITM TOML remain deferred. The detailed command/config/hook contract is in [SERVER.md](SERVER.md). + +## 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. diff --git a/docs/STATUS.md b/docs/STATUS.md index f192bf7..6967a78 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,7 +1,7 @@ # Project status -Last reconciled: 2026-10-06, `dev-v1.0.0` at `759b8f7`, with local tag -management command naming changes. +Last reconciled: 2026-10-06, `dev-v1.0.0` at `b8af8e4`, with local polling +implementation and aligned output, regression coverage, and usage documentation. ## Current state @@ -39,6 +39,14 @@ management command naming changes. See [SERVER.md](SERVER.md), [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 cancel/close/shutdown stop polling. See + [Polling](../README.md#polling) and [D14](DECISIONS.md#d14--fixed-cadence-polling-and-appended-evidence-rows). + ## Next steps and validation gaps 1. Multi-tag serialization/partial output and tag-import confirmation, atomic @@ -48,8 +56,9 @@ management command naming changes. ctui enhancements listed there remain proposals, not dependency commitments. 2. Exercise tag completion, collision confirmation, and typed output in a real terminal; verify USB metadata on physical serial ports. - Investigate the command-suite stall with the current adjacent ctui checkout - before treating it as validated against ctmodbus (see October 5 evidence). + 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. Before `1.0.0rc1` release validation, select the published PyPI ctui version, update the dependency and lockfile if needed, and stop using the local editable override. Then run the [release checklist](../RELEASE_CHECKLIST.md) @@ -59,12 +68,37 @@ management command naming changes. 4. Obtain release approval before publication. Before stable 1.0, adopt tested stable ctui and repeat release gates (D08). -Polling, device cloning, MITM rules, raw/fuzzy requests, tunneling, and +Device cloning, MITM rules, raw/fuzzy requests, tunneling, and historian integration remain deferred. No implementation blocker is established; release readiness lacks final-revision and manual/remote validation. ## Validation evidence +- 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 diff --git a/src/ctmodbus/app.py b/src/ctmodbus/app.py index 87863bd..c0fb8a9 100644 --- a/src/ctmodbus/app.py +++ b/src/ctmodbus/app.py @@ -7,6 +7,7 @@ import asyncio import shlex +import sys from dataclasses import replace from importlib.metadata import version from typing import Literal @@ -24,6 +25,7 @@ from ctmodbus.data_state import DataState from ctmodbus.discovery import complete_serial, suggestions from ctmodbus.operations import ModbusCommandMixin +from ctmodbus.polling import TABLE_ARGUMENTS, Poll, PollCommandMixin from ctmodbus.server import Server from ctmodbus.server_commands import CONFIG_NAME, ServerCommandMixin from ctmodbus.tags import TagCommandMixin, TagStore, parse_tag_document @@ -60,7 +62,7 @@ async def complete_profiles(context): class ModbusApp( # pylint: disable=too-many-public-methods - ServerCommandMixin, TagCommandMixin, ModbusCommandMixin, CtuiApp + PollCommandMixin, ServerCommandMixin, TagCommandMixin, ModbusCommandMixin, CtuiApp ): """A Modbus client with one connection and project-scoped services. @@ -89,6 +91,10 @@ def __init__(self, *, client_factory=create_client, **kwargs): 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.client_state = DataState() self._server_edit_task = None self._prepared_server_imports = {} @@ -207,6 +213,8 @@ async def dispatch( # pylint: disable=too-many-branches,too-many-statements device_command = item.name.startswith( ("connect ", "read ", "write ", "profile ") ) + poll_start = item.name in {"poll tags", "poll raw"} + device_command = device_command or poll_start server_import = item.name == "serve data import" server_start = item.name in { "serve tcp", @@ -239,6 +247,14 @@ async def dispatch( # pylint: disable=too-many-branches,too-many-statements "tags delete", "tags import", } + 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 ): @@ -303,6 +319,20 @@ async def dispatch( # pylint: disable=too-many-branches,too-many-statements self._tag_mutations.add(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 try: if server_import: rejected = await self.prepare_server_import(text, kwargs) @@ -342,6 +372,9 @@ async def dispatch( # pylint: disable=too-many-branches,too-many-statements ) from error raise finally: + 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) @@ -409,6 +442,7 @@ async def close_connection(self): 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 @@ -420,6 +454,20 @@ async def close_connection(self): self._closing = False self.update_progress(0, 0) + async def run_cli(self, arguments, *, stdout=None, stderr=None, program=None): + """Stream polling rows to CLI stdout and await limits before later commands. + + TUI/WUI starts remain background operations. An unlimited CLI poll runs + until interrupted. Delegate parsing and backend lifecycle to ctui. + """ + self._poll_stdout = stdout or sys.stdout + 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 diff --git a/src/ctmodbus/polling.py b/src/ctmodbus/polling.py new file mode 100644 index 0000000..487ce0b --- /dev/null +++ b/src/ctmodbus/polling.py @@ -0,0 +1,438 @@ +"""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.""" + stream = self.app._poll_stdout + if stream is not None: + print(text, file=stream, flush=True) + return + layout = getattr(self.app, "layout", None) + current = layout.output_field.text if layout else self.app.output_text + output = f"{current.rstrip()}\n{text}" if current else text + self.app.output_text = output + if layout: + layout.set_output(output) + runtime = getattr(self.app, "app", None) + if runtime: + runtime.invalidate() + + 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; connect 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/tests/test_polling.py b/tests/test_polling.py new file mode 100644 index 0000000..5da35f4 --- /dev/null +++ b/tests/test_polling.py @@ -0,0 +1,392 @@ +"""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("connect 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_fixed_cadence_skips_inflight_and_manual_work(self): + """A slow response skips ticks without overlapping or adding response delay.""" + starts = [] + original = self.client.read_coils + + async def delayed(**kwargs): + starts.append(asyncio.get_running_loop().time()) + if len(starts) == 1: + await asyncio.sleep(0.14) + return await original(**kwargs) + + self.client.read_coils = delayed + await self.app.dispatch("poll raw --coils 0 --interval 0.05 --count 3") + await self.finish() + self.assertGreaterEqual(self.app.poller.skipped, 2) + self.assertLess(starts[1] - starts[0], 0.19) + self.assertGreater(starts[1] - starts[0], 0.13) + self.assertEqual(self.app.poller.completed, 3) + 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("cancel") + self.assertFalse(self.app.poller.active) + self.assertFalse(self.client.connected) + await self.app.dispatch("connect 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("close"), 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("close") + 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", + "connect 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 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_tui.py b/tests/test_tui.py index 28a8940..9609d91 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -47,7 +47,21 @@ async def on_ready(self): self.assertEqual( await asyncio.wait_for(finished.get(), 3), name ) - pipe.send_text("exit\n") + 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: From b05ce13e354fcd61e2033ec623f14859c48dac1c Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Tue, 6 Oct 2026 19:08:03 -0600 Subject: [PATCH 24/38] fix: append output from ctmodbus commands --- AGENTS.md | 4 +++ CHANGELOG.md | 3 ++ README.md | 4 +++ docs/DECISIONS.md | 10 +++++++ docs/STATUS.md | 12 ++++++-- src/ctmodbus/app.py | 4 +-- src/ctmodbus/server_commands.py | 52 ++++++++++++++++++++------------- src/ctmodbus/tags.py | 25 +++++++++------- tests/test_commands.py | 10 +++++++ tests/test_server.py | 11 +++++++ tests/test_tags.py | 15 ++++++++++ 11 files changed, 114 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3effda5..18dc683 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,10 @@ 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. +All ctmodbus-owned commands must append output by default using +`CommandResult.append`; replace or clear output only when explicitly requested. +Leave ctui-owned commands and their output policies unchanged. + 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 23365c4..6be9ef0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [1.0.0rc1] - Unreleased +- Append output from all ctmodbus-owned tag, connection, server and proxy + commands; retain ctui built-in command behavior. + - Add fixed-cadence `poll tags` and multi-table `poll raw` with configurable intervals, count/duration limits, status and graceful stop. Append compact aligned typed/raw rows with a `#` counter in terminal and browser sessions, diff --git a/README.md b/README.md index 3c9dca6..3180e72 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,10 @@ Concurrent exports still assume sequential use. See the [documented concurrency limitations](docs/DECISIONS.md#d12--implementation-discrepancies-and-ctui-proposals) before embedding concurrent tag-management or project-switching commands. +ctmodbus-owned commands append to existing output, including all `tags`, +`connect`, `serve`, and `proxy` results. Built-in ctui commands such as `configs`, +`history`, and `project` retain their framework-defined output behavior. + ## Polling Start one poll in the TUI or WUI; commands remain available while it runs. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index df9e590..8c78d6b 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -313,3 +313,13 @@ 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 — Append output from application-owned commands + +Accepted at the owner's request, 2026-10-06. + +All ctmodbus-owned commands default to `CommandResult.append` for output; +replace or clear only when explicitly requested. This includes discovery, tag +inspection/exchange, server configuration/status, and proxy controls as well as +device operations. Keep the rule in AGENTS.md for future commands. Leave ctui +built-ins, including configs, history, projects, help, and clear, unchanged. diff --git a/docs/STATUS.md b/docs/STATUS.md index 6967a78..26548f7 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,7 +1,7 @@ # Project status -Last reconciled: 2026-10-06, `dev-v1.0.0` at `b8af8e4`, with local polling -implementation and aligned output, regression coverage, and usage documentation. +Last reconciled: 2026-10-06, `dev-v1.0.0` at `236ef82`, with local application-owned +command output append behavior. ## Current state @@ -74,6 +74,14 @@ release readiness lacks final-revision and manual/remote validation. ## Validation evidence +- October 6 append policy on `236ef82` plus the working tree: all 97 tests + passed with adjacent editable ctui. Existing command/tag/server regressions + now assert append semantics for application-owned tags/connect/serve/proxy + output, including empty tag lists. Black/isort, pylint (10.00/10), and + whitespace checks passed. All these command results append; + configs/history/projects and other ctui built-ins remain unchanged. See + [D15](DECISIONS.md#d15--append-output-from-application-owned-commands). + - 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 diff --git a/src/ctmodbus/app.py b/src/ctmodbus/app.py index c0fb8a9..305bb32 100644 --- a/src/ctmodbus/app.py +++ b/src/ctmodbus/app.py @@ -490,8 +490,8 @@ async def on_stop(self): @command(name="connect") async def connect_suggestions(self): - """List local serial devices and listening services.""" - return await asyncio.to_thread(suggestions) + """Append serial-device and local-service discovery without connecting.""" + return CommandResult.append(await asyncio.to_thread(suggestions)) async def open_connection(self, settings): """Return an appended OPENED result after connection and recording start. diff --git a/src/ctmodbus/server_commands.py b/src/ctmodbus/server_commands.py index bbfa9e9..a665e02 100644 --- a/src/ctmodbus/server_commands.py +++ b/src/ctmodbus/server_commands.py @@ -48,7 +48,7 @@ class ServerCommandMixin: # pylint: disable=too-many-public-methods - """Commands share normal CTUI parsing, dispatch, history and project guards.""" + """Commands share CTUI dispatch and guards; successful output always appends.""" async def server_definition(self): """Load current project definition, defaulting to all-zero full address maps.""" @@ -124,13 +124,15 @@ async def serve_data_import(self, path: Path, replace: bool = False): (CONFIG_NAME, json.dumps(config)), ) await self.backend.touch() - return f"Imported server definition with {len(tags)} tags." + return CommandResult.append( + f"Imported server definition with {len(tags)} tags." + ) @command(name="serve data export", arguments=PATH_ARG) async def serve_data_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}." + return CommandResult.append(f"Exported server definition to {destination}.") @command( name="serve data clear", arguments={"confirm": Argument(flags=("--confirm",))} @@ -140,7 +142,7 @@ async def serve_data_clear(self, confirm: bool = False): if not confirm: raise CommandError("Use --confirm to clear the server definition") await self.save_server_definition(server_config.definition(False)) - return ( + return CommandResult.append( "Server definition cleared; no addresses available. Project tags retained." ) @@ -153,7 +155,7 @@ async def serve_data_unit(self, unit: int): config = await self.server_definition() config["unit"] = unit await self.save_server_definition(config) - return f"Server unit set to {unit}." + return CommandResult.append(f"Server unit set to {unit}.") @command( name="serve data seed", @@ -164,7 +166,7 @@ async def serve_data_seed(self, seed: int): config = await self.server_definition() config["seed"] = seed await self.save_server_definition(config) - return f"Server seed set to {seed}." + return CommandResult.append(f"Server seed set to {seed}.") @command( name="serve data identity", @@ -187,7 +189,7 @@ async def serve_data_identity( "revision": revision, } await self.save_server_definition(config) - return "Server identification updated." + return CommandResult.append("Server identification updated.") @command( name="serve data table", @@ -211,7 +213,7 @@ async def serve_data_table( unmapped=unmapped, default=self.raw_value(default) ) await self.save_server_definition(config) - return f"{table}: unmapped={unmapped}, default={default}." + return CommandResult.append(f"{table}: unmapped={unmapped}, default={default}.") @staticmethod def raw_value(text): @@ -263,7 +265,9 @@ async def set_server_rule(self, target, selection, rule): ] 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}." + return CommandResult.append( + f"Set {rule['mode']} behavior for {target} {selection}." + ) @command( name="serve data set", @@ -337,7 +341,7 @@ async def set_server_hook(self, name, module, function, interval=None): if interval is not None: config["hooks"]["tick_seconds"] = interval await self.save_server_definition(config) - return f"Configured {name}: {function}." + return CommandResult.append(f"Configured {name}: {function}.") @command( name="serve hook read", @@ -381,7 +385,7 @@ async def serve_hook_clear(self): config = await self.server_definition() config["hooks"] = {} await self.save_server_definition(config) - return "Server hooks cleared." + return CommandResult.append("Server hooks cleared.") async def validate_server_tags(self, config): """Require server tags to match their shared project definitions.""" @@ -415,7 +419,7 @@ async def serve_data_remove(self, target: str, selection: str): else: raise CommandError("Target must be tag or a Modbus table") await self.save_server_definition(config) - return "Server rules removed." + return CommandResult.append("Server rules removed.") @command(name="serve data validate") async def serve_data_validate(self): @@ -423,7 +427,7 @@ async def serve_data_validate(self): config = await self.server_definition() await self.validate_server_tags(config) await asyncio.to_thread(Simulator.load_hooks, config) - return ( + return CommandResult.append( "Server configuration valid. " "Listener binding and hook behavior are not tested." ) @@ -458,7 +462,7 @@ async def show_server_data(self): ] lines.append(f"{name} = {decode_tag_value(tag, raw)!r}") lines.append(self.server.state.show(await self.tags.list())) - return "\n".join(lines) + return CommandResult.append("\n".join(lines)) @command(name="connect data show") async def connect_data_show(self): @@ -470,7 +474,9 @@ async def connect_data_show(self): if self.connection.connected else "Disconnected; last session evidence retained" ) - return heading + "\n" + self.client_state.show(await self.tags.list()) + return CommandResult.append( + heading + "\n" + self.client_state.show(await self.tags.list()) + ) async def start_server(self, settings, foreground=False, **tls): """Start in background for TUI, or wait until stopped in explicit @@ -586,13 +592,13 @@ async def serve_ascii( async def serve_stop(self): """Stop the listener and drain local/proxy work; keep the client connection.""" await self.server.stop() - return "Server stopped." + return CommandResult.append("Server stopped.") @command(name="serve status") async def serve_status(self): """Show listener, proxy mode and the last hook/transport/storage error.""" mode = "proxy" if self.server.proxy else "local" - return ( + return CommandResult.append( f"Server: {self.server.label}\nMode: {mode}\n" f"Last error: {self.server.last_error or 'none'}" ) @@ -612,7 +618,7 @@ async def serve_data_reset(self, confirm: bool = False): current = self.server.simulator self.server.simulator = Simulator(current.config, current.hooks) self.server.state.rows.clear() - return "Local simulation reset." + return CommandResult.append("Local simulation reset.") @command(name="proxy enable") async def proxy_enable(self): @@ -633,7 +639,9 @@ async def proxy_enable(self): ): raise CommandError("Proxy cannot forward to its own listener") self.server.proxy = True - return "Proxy enabled. New requests use the upstream device." + return CommandResult.append( + "Proxy enabled. New requests use the upstream device." + ) @command(name="proxy disable") async def proxy_disable(self): @@ -641,11 +649,13 @@ async def proxy_disable(self): normally. """ self.server.proxy = False - return "Proxy disabled. New requests use local simulation." + return CommandResult.append( + "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}" + return CommandResult.append(f"Proxy: {mode}\nUpstream: {upstream}") diff --git a/src/ctmodbus/tags.py b/src/ctmodbus/tags.py index eb0b6c5..31d1db3 100644 --- a/src/ctmodbus/tags.py +++ b/src/ctmodbus/tags.py @@ -500,8 +500,8 @@ 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 + delete/list/inspection/file commands return append CommandResult. Expected + input/protocol errors use CommandError. Store and unexpected file errors can propagate; no mixin-wide serialization is added. """ @@ -563,11 +563,11 @@ async def tag_create( @command(name="tags list") async def tag_list(self): - """List tags in the active project.""" + """Append the active project tag table, or an empty-list message.""" tags = await self.tags.list() if not tags: - return "No tags." - return tabulate( + return CommandResult.append("No tags.") + output = tabulate( [ ( tag.name, @@ -582,14 +582,17 @@ async def tag_list(self): ], headers=("Name", "Table", "Address", "Count", "Type", "Byte", "Word"), ) + return CommandResult.append(output) @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.""" + """Append 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()) + return CommandResult.append( + "\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): @@ -697,7 +700,7 @@ async def write_tag(self, name: str, value: str): }, ) async def tag_export(self, path: Path): - """Write name-sorted project tags to PATH and return destination text. + """Write name-sorted project tags to PATH and append destination text. Append .toml unless the existing suffix matches case-insensitively. Synchronously write PATH.tmp then os.replace the destination. The rename @@ -718,7 +721,7 @@ async def tag_export(self, path: Path): except FileNotFoundError: pass raise CommandError(f"Cannot export tags: {error}") from error - return f"Exported tags to {path}." + return CommandResult.append(f"Exported tags to {path}.") @command( name="tags import", @@ -728,7 +731,7 @@ async def tag_export(self, path: Path): }, ) async def tag_import(self, path: Path, replace: bool = False): - """Validate PATH and merge tags, returning the imported count as text. + """Validate PATH and merge tags, appending the imported count. Dispatch supplies validated, confirmed data for the current task and guards project/tag mutations. Direct calls validate the file and never @@ -747,4 +750,4 @@ async def tag_import(self, path: Path, replace: bool = False): + ". Re-run with --replace to overwrite them." ) await self.tags.import_all(tags, replace=replace) - return f"Imported {len(tags)} tags." + return CommandResult.append(f"Imported {len(tags)} tags.") diff --git a/tests/test_commands.py b/tests/test_commands.py index db243d1..f83180c 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -113,6 +113,16 @@ async def asyncSetUp(self): ) await self.app.backend.open() + def verify_output(command, result): + """Require append semantics for ctmodbus output, preserving ctui defaults.""" + if ( + command.name.split()[0] in {"tags", "connect", "serve", "proxy"} + and result.output is not None + ): + self.assertTrue(result.append_output, command.name) + + self.app.on("command_finished", verify_output) + async def asyncTearDown(self): """Stop application tasks, close project storage, and remove temporary data.""" await self.app.on_stop() diff --git a/tests/test_server.py b/tests/test_server.py index 1a674ed..fcd4bf9 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -189,6 +189,17 @@ async def asyncSetUp(self): self.folder = tempfile.TemporaryDirectory() self.app = ModbusApp(data_dir=self.folder.name) await self.app.backend.open() + + def verify_output(command, result): + """Require append semantics for ctmodbus output, preserving ctui defaults.""" + if ( + command.name.split()[0] in {"tags", "connect", "serve", "proxy"} + and result.output is not None + ): + self.assertTrue(result.append_output, command.name) + + self.app.on("command_finished", verify_output) + self.clients = [] async def asyncTearDown(self): diff --git a/tests/test_tags.py b/tests/test_tags.py index 2b62dae..855c649 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -96,6 +96,16 @@ async def asyncSetUp(self): ) await self.app.backend.open() + def verify_output(command, result): + """Require append semantics for ctmodbus output, preserving ctui defaults.""" + if ( + command.name.split()[0] in {"tags", "connect", "serve", "proxy"} + and result.output is not None + ): + self.assertTrue(result.append_output, command.name) + + self.app.on("command_finished", verify_output) + async def asyncTearDown(self): """Stop application tasks, close project storage, and remove temporary data.""" await self.app.on_stop() @@ -110,6 +120,7 @@ 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.assertTrue(result.append_output) self.assertIn("timer", result.output) self.assertIn("little", result.output) result = await self.app.dispatch("tags show timer") @@ -119,6 +130,10 @@ async def test_create_list_rename_delete_and_project_scope(self): await self.app.dispatch("tags delete duration") self.assertEqual(await self.app.tags.list(), []) + result = await self.app.dispatch("tags list") + self.assertTrue(result.append_output) + self.assertEqual(result.output, "No tags.") + await self.app.dispatch("tags create local coil 0 bool") await self.app.dispatch("project create other") self.assertEqual(await self.app.tags.list(), []) From aad20cd347c0eac15f28ffc22425d97ec0e324fe Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Tue, 6 Oct 2026 19:19:42 -0600 Subject: [PATCH 25/38] Revert "fix: append output from ctmodbus commands" This reverts commit b05ce13e354fcd61e2033ec623f14859c48dac1c. --- AGENTS.md | 4 --- CHANGELOG.md | 3 -- README.md | 4 --- docs/DECISIONS.md | 10 ------- docs/STATUS.md | 12 ++------ src/ctmodbus/app.py | 4 +-- src/ctmodbus/server_commands.py | 52 +++++++++++++-------------------- src/ctmodbus/tags.py | 25 +++++++--------- tests/test_commands.py | 10 ------- tests/test_server.py | 11 ------- tests/test_tags.py | 15 ---------- 11 files changed, 36 insertions(+), 114 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 18dc683..3effda5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,10 +30,6 @@ 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. -All ctmodbus-owned commands must append output by default using -`CommandResult.append`; replace or clear output only when explicitly requested. -Leave ctui-owned commands and their output policies unchanged. - 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6be9ef0..23365c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,6 @@ ## [1.0.0rc1] - Unreleased -- Append output from all ctmodbus-owned tag, connection, server and proxy - commands; retain ctui built-in command behavior. - - Add fixed-cadence `poll tags` and multi-table `poll raw` with configurable intervals, count/duration limits, status and graceful stop. Append compact aligned typed/raw rows with a `#` counter in terminal and browser sessions, diff --git a/README.md b/README.md index 3180e72..3c9dca6 100644 --- a/README.md +++ b/README.md @@ -156,10 +156,6 @@ Concurrent exports still assume sequential use. See the [documented concurrency limitations](docs/DECISIONS.md#d12--implementation-discrepancies-and-ctui-proposals) before embedding concurrent tag-management or project-switching commands. -ctmodbus-owned commands append to existing output, including all `tags`, -`connect`, `serve`, and `proxy` results. Built-in ctui commands such as `configs`, -`history`, and `project` retain their framework-defined output behavior. - ## Polling Start one poll in the TUI or WUI; commands remain available while it runs. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 8c78d6b..df9e590 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -313,13 +313,3 @@ 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 — Append output from application-owned commands - -Accepted at the owner's request, 2026-10-06. - -All ctmodbus-owned commands default to `CommandResult.append` for output; -replace or clear only when explicitly requested. This includes discovery, tag -inspection/exchange, server configuration/status, and proxy controls as well as -device operations. Keep the rule in AGENTS.md for future commands. Leave ctui -built-ins, including configs, history, projects, help, and clear, unchanged. diff --git a/docs/STATUS.md b/docs/STATUS.md index 26548f7..6967a78 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,7 +1,7 @@ # Project status -Last reconciled: 2026-10-06, `dev-v1.0.0` at `236ef82`, with local application-owned -command output append behavior. +Last reconciled: 2026-10-06, `dev-v1.0.0` at `b8af8e4`, with local polling +implementation and aligned output, regression coverage, and usage documentation. ## Current state @@ -74,14 +74,6 @@ release readiness lacks final-revision and manual/remote validation. ## Validation evidence -- October 6 append policy on `236ef82` plus the working tree: all 97 tests - passed with adjacent editable ctui. Existing command/tag/server regressions - now assert append semantics for application-owned tags/connect/serve/proxy - output, including empty tag lists. Black/isort, pylint (10.00/10), and - whitespace checks passed. All these command results append; - configs/history/projects and other ctui built-ins remain unchanged. See - [D15](DECISIONS.md#d15--append-output-from-application-owned-commands). - - 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 diff --git a/src/ctmodbus/app.py b/src/ctmodbus/app.py index 305bb32..c0fb8a9 100644 --- a/src/ctmodbus/app.py +++ b/src/ctmodbus/app.py @@ -490,8 +490,8 @@ async def on_stop(self): @command(name="connect") async def connect_suggestions(self): - """Append serial-device and local-service discovery without connecting.""" - return CommandResult.append(await asyncio.to_thread(suggestions)) + """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. diff --git a/src/ctmodbus/server_commands.py b/src/ctmodbus/server_commands.py index a665e02..bbfa9e9 100644 --- a/src/ctmodbus/server_commands.py +++ b/src/ctmodbus/server_commands.py @@ -48,7 +48,7 @@ class ServerCommandMixin: # pylint: disable=too-many-public-methods - """Commands share CTUI dispatch and guards; successful output always appends.""" + """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.""" @@ -124,15 +124,13 @@ async def serve_data_import(self, path: Path, replace: bool = False): (CONFIG_NAME, json.dumps(config)), ) await self.backend.touch() - return CommandResult.append( - f"Imported server definition with {len(tags)} tags." - ) + return f"Imported server definition with {len(tags)} tags." @command(name="serve data export", arguments=PATH_ARG) async def serve_data_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 CommandResult.append(f"Exported server definition to {destination}.") + return f"Exported server definition to {destination}." @command( name="serve data clear", arguments={"confirm": Argument(flags=("--confirm",))} @@ -142,7 +140,7 @@ async def serve_data_clear(self, confirm: bool = False): if not confirm: raise CommandError("Use --confirm to clear the server definition") await self.save_server_definition(server_config.definition(False)) - return CommandResult.append( + return ( "Server definition cleared; no addresses available. Project tags retained." ) @@ -155,7 +153,7 @@ async def serve_data_unit(self, unit: int): config = await self.server_definition() config["unit"] = unit await self.save_server_definition(config) - return CommandResult.append(f"Server unit set to {unit}.") + return f"Server unit set to {unit}." @command( name="serve data seed", @@ -166,7 +164,7 @@ async def serve_data_seed(self, seed: int): config = await self.server_definition() config["seed"] = seed await self.save_server_definition(config) - return CommandResult.append(f"Server seed set to {seed}.") + return f"Server seed set to {seed}." @command( name="serve data identity", @@ -189,7 +187,7 @@ async def serve_data_identity( "revision": revision, } await self.save_server_definition(config) - return CommandResult.append("Server identification updated.") + return "Server identification updated." @command( name="serve data table", @@ -213,7 +211,7 @@ async def serve_data_table( unmapped=unmapped, default=self.raw_value(default) ) await self.save_server_definition(config) - return CommandResult.append(f"{table}: unmapped={unmapped}, default={default}.") + return f"{table}: unmapped={unmapped}, default={default}." @staticmethod def raw_value(text): @@ -265,9 +263,7 @@ async def set_server_rule(self, target, selection, rule): ] existing.append({"start": span.start, "end": span.stop - 1, **rule}) await self.save_server_definition(config) - return CommandResult.append( - f"Set {rule['mode']} behavior for {target} {selection}." - ) + return f"Set {rule['mode']} behavior for {target} {selection}." @command( name="serve data set", @@ -341,7 +337,7 @@ async def set_server_hook(self, name, module, function, interval=None): if interval is not None: config["hooks"]["tick_seconds"] = interval await self.save_server_definition(config) - return CommandResult.append(f"Configured {name}: {function}.") + return f"Configured {name}: {function}." @command( name="serve hook read", @@ -385,7 +381,7 @@ async def serve_hook_clear(self): config = await self.server_definition() config["hooks"] = {} await self.save_server_definition(config) - return CommandResult.append("Server hooks cleared.") + return "Server hooks cleared." async def validate_server_tags(self, config): """Require server tags to match their shared project definitions.""" @@ -419,7 +415,7 @@ async def serve_data_remove(self, target: str, selection: str): else: raise CommandError("Target must be tag or a Modbus table") await self.save_server_definition(config) - return CommandResult.append("Server rules removed.") + return "Server rules removed." @command(name="serve data validate") async def serve_data_validate(self): @@ -427,7 +423,7 @@ async def serve_data_validate(self): config = await self.server_definition() await self.validate_server_tags(config) await asyncio.to_thread(Simulator.load_hooks, config) - return CommandResult.append( + return ( "Server configuration valid. " "Listener binding and hook behavior are not tested." ) @@ -462,7 +458,7 @@ async def show_server_data(self): ] lines.append(f"{name} = {decode_tag_value(tag, raw)!r}") lines.append(self.server.state.show(await self.tags.list())) - return CommandResult.append("\n".join(lines)) + return "\n".join(lines) @command(name="connect data show") async def connect_data_show(self): @@ -474,9 +470,7 @@ async def connect_data_show(self): if self.connection.connected else "Disconnected; last session evidence retained" ) - return CommandResult.append( - heading + "\n" + self.client_state.show(await self.tags.list()) - ) + return heading + "\n" + self.client_state.show(await self.tags.list()) async def start_server(self, settings, foreground=False, **tls): """Start in background for TUI, or wait until stopped in explicit @@ -592,13 +586,13 @@ async def serve_ascii( async def serve_stop(self): """Stop the listener and drain local/proxy work; keep the client connection.""" await self.server.stop() - return CommandResult.append("Server stopped.") + return "Server stopped." @command(name="serve status") async def serve_status(self): """Show listener, proxy mode and the last hook/transport/storage error.""" mode = "proxy" if self.server.proxy else "local" - return CommandResult.append( + return ( f"Server: {self.server.label}\nMode: {mode}\n" f"Last error: {self.server.last_error or 'none'}" ) @@ -618,7 +612,7 @@ async def serve_data_reset(self, confirm: bool = False): current = self.server.simulator self.server.simulator = Simulator(current.config, current.hooks) self.server.state.rows.clear() - return CommandResult.append("Local simulation reset.") + return "Local simulation reset." @command(name="proxy enable") async def proxy_enable(self): @@ -639,9 +633,7 @@ async def proxy_enable(self): ): raise CommandError("Proxy cannot forward to its own listener") self.server.proxy = True - return CommandResult.append( - "Proxy enabled. New requests use the upstream device." - ) + return "Proxy enabled. New requests use the upstream device." @command(name="proxy disable") async def proxy_disable(self): @@ -649,13 +641,11 @@ async def proxy_disable(self): normally. """ self.server.proxy = False - return CommandResult.append( - "Proxy disabled. New requests use local simulation." - ) + 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 CommandResult.append(f"Proxy: {mode}\nUpstream: {upstream}") + return f"Proxy: {mode}\nUpstream: {upstream}" diff --git a/src/ctmodbus/tags.py b/src/ctmodbus/tags.py index 31d1db3..eb0b6c5 100644 --- a/src/ctmodbus/tags.py +++ b/src/ctmodbus/tags.py @@ -500,8 +500,8 @@ class TagCommandMixin: """Commands for typed tags using app-owned tags and protocol services. ctui signatures/decorators define command syntax. Read/write/create/rename/ - delete/list/inspection/file commands return append CommandResult. Expected - input/protocol errors use CommandError. Store and + 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. """ @@ -563,11 +563,11 @@ async def tag_create( @command(name="tags list") async def tag_list(self): - """Append the active project tag table, or an empty-list message.""" + """List tags in the active project.""" tags = await self.tags.list() if not tags: - return CommandResult.append("No tags.") - output = tabulate( + return "No tags." + return tabulate( [ ( tag.name, @@ -582,17 +582,14 @@ async def tag_list(self): ], headers=("Name", "Table", "Address", "Count", "Type", "Byte", "Word"), ) - return CommandResult.append(output) @command(name="tags show", arguments={"name": Argument(completer=complete_tags)}) async def tag_show(self, name: str): - """Append one tag and its derived address range.""" + """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 CommandResult.append( - "\n".join(f"{key}: {value}" for key, value in values.items()) - ) + 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): @@ -700,7 +697,7 @@ async def write_tag(self, name: str, value: str): }, ) async def tag_export(self, path: Path): - """Write name-sorted project tags to PATH and append destination text. + """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 @@ -721,7 +718,7 @@ async def tag_export(self, path: Path): except FileNotFoundError: pass raise CommandError(f"Cannot export tags: {error}") from error - return CommandResult.append(f"Exported tags to {path}.") + return f"Exported tags to {path}." @command( name="tags import", @@ -731,7 +728,7 @@ async def tag_export(self, path: Path): }, ) async def tag_import(self, path: Path, replace: bool = False): - """Validate PATH and merge tags, appending the imported count. + """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 @@ -750,4 +747,4 @@ async def tag_import(self, path: Path, replace: bool = False): + ". Re-run with --replace to overwrite them." ) await self.tags.import_all(tags, replace=replace) - return CommandResult.append(f"Imported {len(tags)} tags.") + return f"Imported {len(tags)} tags." diff --git a/tests/test_commands.py b/tests/test_commands.py index f83180c..db243d1 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -113,16 +113,6 @@ async def asyncSetUp(self): ) await self.app.backend.open() - def verify_output(command, result): - """Require append semantics for ctmodbus output, preserving ctui defaults.""" - if ( - command.name.split()[0] in {"tags", "connect", "serve", "proxy"} - and result.output is not None - ): - self.assertTrue(result.append_output, command.name) - - self.app.on("command_finished", verify_output) - async def asyncTearDown(self): """Stop application tasks, close project storage, and remove temporary data.""" await self.app.on_stop() diff --git a/tests/test_server.py b/tests/test_server.py index fcd4bf9..1a674ed 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -189,17 +189,6 @@ async def asyncSetUp(self): self.folder = tempfile.TemporaryDirectory() self.app = ModbusApp(data_dir=self.folder.name) await self.app.backend.open() - - def verify_output(command, result): - """Require append semantics for ctmodbus output, preserving ctui defaults.""" - if ( - command.name.split()[0] in {"tags", "connect", "serve", "proxy"} - and result.output is not None - ): - self.assertTrue(result.append_output, command.name) - - self.app.on("command_finished", verify_output) - self.clients = [] async def asyncTearDown(self): diff --git a/tests/test_tags.py b/tests/test_tags.py index 855c649..2b62dae 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -96,16 +96,6 @@ async def asyncSetUp(self): ) await self.app.backend.open() - def verify_output(command, result): - """Require append semantics for ctmodbus output, preserving ctui defaults.""" - if ( - command.name.split()[0] in {"tags", "connect", "serve", "proxy"} - and result.output is not None - ): - self.assertTrue(result.append_output, command.name) - - self.app.on("command_finished", verify_output) - async def asyncTearDown(self): """Stop application tasks, close project storage, and remove temporary data.""" await self.app.on_stop() @@ -120,7 +110,6 @@ 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.assertTrue(result.append_output) self.assertIn("timer", result.output) self.assertIn("little", result.output) result = await self.app.dispatch("tags show timer") @@ -130,10 +119,6 @@ async def test_create_list_rename_delete_and_project_scope(self): await self.app.dispatch("tags delete duration") self.assertEqual(await self.app.tags.list(), []) - result = await self.app.dispatch("tags list") - self.assertTrue(result.append_output) - self.assertEqual(result.output, "No tags.") - await self.app.dispatch("tags create local coil 0 bool") await self.app.dispatch("project create other") self.assertEqual(await self.app.tags.list(), []) From 5ffde8cbda5fc6350fd4a778b9f2c91ee6006d18 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Tue, 6 Oct 2026 20:09:09 -0600 Subject: [PATCH 26/38] feat: add server and proxy request logging --- CHANGELOG.md | 4 + README.md | 40 ++++++++++ docs/DECISIONS.md | 18 +++++ docs/SERVER.md | 12 +++ docs/STATUS.md | 31 +++++++- src/ctmodbus/app.py | 18 +++++ src/ctmodbus/polling.py | 14 +--- src/ctmodbus/server.py | 134 +++++++++++++++++++++++++++++--- src/ctmodbus/server_commands.py | 70 ++++++++++++++--- tests/test_server.py | 123 +++++++++++++++++++++++++++++ 10 files changed, 430 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23365c4..d974d59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [1.0.0rc1] - Unreleased +- Display correlated server-arrival and proxy-forwarding request lines by default, + with per-transport/proxy `--quiet`, independent live logging switches, visible + failures, bounded write previews, and full write payloads in server records. + - Add fixed-cadence `poll tags` and multi-table `poll raw` with configurable intervals, count/duration limits, status and graceful stop. Append compact aligned typed/raw rows with a `#` counter in terminal and browser sessions, diff --git a/README.md b/README.md index 3c9dca6..aff3c66 100644 --- a/README.md +++ b/README.md @@ -360,3 +360,43 @@ connect data show `proxy enable` routes incoming server requests through the connected client; `proxy disable` restores local behavior. See [server and proxy usage](docs/SERVER.md) for all commands, foreground CLI serving, hooks, defaults and evidence semantics. + +### Server and proxy request output + +Server listeners append one line per decoded incoming request by default. +Enabling the proxy adds a forwarding line with the same application-local request +ID. Lines include UTC millisecond timestamps, source, peer or upstream endpoint, +unit, function/table, and address range. Writes show compact bits or four-digit +hex registers, capped at 16 displayed values with an omitted-value count; server +operation records retain the full write payload. Arrival/forwarding lines do not +imply a successful response or write acknowledgement. + +```text +serve tcp 127.0.0.1 --port 5020 --quiet +proxy enable --quiet +serve logging on +proxy logging on +serve logging off +proxy logging off +serve status +proxy status +``` + +`--quiet` is available on every `serve` transport and on `proxy enable`. Server +arrival and proxy forwarding logging are independent. Live `logging on/off` +commands change routine verbosity without stopping the listener or disabling +records. Starting a listener or enabling the proxy resets its logging policy +from that command's `--quiet` option; settings are runtime-only. + +```text +2026-10-06T14:32:05.123+00:00 SERVER #42 peer=('127.0.0.1', 53120) unit=1 read holding_registers 10-12 +2026-10-06T14:32:05.124+00:00 PROXY #42 upstream=TCP 192.168.1.20:502 unit=7 read holding_registers 10-12 +``` + +Request failures remain visible in quiet mode. Failed proxy writes distinguish +potentially unconfirmed outcomes; upstream exceptions are shown without implying +forwarding success. Display escapes control characters. Polling and server logs +share one synchronous append path for terminal, browser, and CLI output. CLI +listeners still need `--foreground` to remain running. Malformed/unsupported +requests that the decoder reports also produce diagnostic lines. Future MITM +logging is not implemented. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index df9e590..a6ed7f1 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -313,3 +313,21 @@ 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. diff --git a/docs/SERVER.md b/docs/SERVER.md index 0736ee3..15a709b 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -277,3 +277,15 @@ 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. Future MITM rules will use a separate TOML file; only request/response extension seams exist today. + +## Request display controls + +Listeners default to appending request-arrival lines; proxy mode additionally +appends forwarding lines with the same request ID. `--quiet` on any `serve` +transport suppresses routine server lines. `proxy enable --quiet` independently +suppresses routine forwarding lines. `serve logging on/off` and +`proxy logging on/off` update these policies live, and status commands show them. +Errors bypass both quiet settings; operation recording is independent of display. +Request IDs increase across listener restarts within an application, and verbosity +settings are runtime-only. See the [usage examples](../README.md#server-and-proxy-request-output) +for timestamps, compact previews, and CLI behavior. diff --git a/docs/STATUS.md b/docs/STATUS.md index 6967a78..ea9b892 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,7 +1,7 @@ # Project status -Last reconciled: 2026-10-06, `dev-v1.0.0` at `b8af8e4`, with local polling -implementation and aligned output, regression coverage, and usage documentation. +Last reconciled: 2026-10-06, `dev-v1.0.0` at `aad20cd`, with local server/proxy +request logging, quiet controls, and shared runtime output appends. ## Current state @@ -47,6 +47,17 @@ implementation and aligned output, regression coverage, and usage documentation. Targets are stable and cancel/close/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 enable` support `--quiet`; independent + live `serve 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](SERVER.md#request-display-controls) and + [D15](DECISIONS.md#d15--independent-server-and-proxy-request-display). +- The `aad20cd` revert removed the application-wide append default. Ordinary + command output follows existing per-command behavior; runtime request/polling + lines explicitly append. ctui built-in commands remain unchanged. + ## Next steps and validation gaps 1. Multi-tag serialization/partial output and tag-import confirmation, atomic @@ -74,6 +85,22 @@ release readiness lacks final-revision and manual/remote validation. ## Validation evidence +- 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 diff --git a/src/ctmodbus/app.py b/src/ctmodbus/app.py index c0fb8a9..42252ba 100644 --- a/src/ctmodbus/app.py +++ b/src/ctmodbus/app.py @@ -119,6 +119,24 @@ def __init__(self, *, client_factory=create_client, **kwargs): "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 diff --git a/src/ctmodbus/polling.py b/src/ctmodbus/polling.py index 487ce0b..a7a0879 100644 --- a/src/ctmodbus/polling.py +++ b/src/ctmodbus/polling.py @@ -144,19 +144,7 @@ def active(self): def append(self, text): """Append one safe line/header to TUI/WUI output or the CLI stream.""" - stream = self.app._poll_stdout - if stream is not None: - print(text, file=stream, flush=True) - return - layout = getattr(self.app, "layout", None) - current = layout.output_field.text if layout else self.app.output_text - output = f"{current.rstrip()}\n{text}" if current else text - self.app.output_text = output - if layout: - layout.set_output(output) - runtime = getattr(self.app, "app", None) - if runtime: - runtime.invalidate() + self.app.append_output(text) async def start(self, columns, interval, count, duration): """Validate limits/session; reject replacement and empty targets. diff --git a/src/ctmodbus/server.py b/src/ctmodbus/server.py index 21fc2e8..16fb0b2 100644 --- a/src/ctmodbus/server.py +++ b/src/ctmodbus/server.py @@ -46,6 +46,7 @@ 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 @@ -115,6 +116,14 @@ def callback_data(self, data, addr=None): 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, @@ -142,13 +151,18 @@ def handle_later(self): 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) + 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) @@ -156,6 +170,7 @@ async def dispatch_request(self, request, address): 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, @@ -231,6 +246,9 @@ def __init__(self, app): 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 @@ -248,7 +266,7 @@ async def record(self, direction, item): self.last_error = f"Recording warning: {error}" async def start( - self, config, settings, cert_file=None, key_file=None, ca_file=None + 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. @@ -304,6 +322,8 @@ async def start( 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( @@ -385,6 +405,7 @@ async def ticks(self): 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)} ) @@ -406,13 +427,70 @@ def values(request): ) return list(request.registers) - async def handle(self, request): - """Capture mode before queuing and serialize complete downstream exchanges.""" + 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: - return await self.route(request, proxy) + 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): + 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. @@ -445,6 +523,8 @@ async def route(self, request, proxy): "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, @@ -452,7 +532,9 @@ async def route(self, request, proxy): }, ) response = ( - await self.forward(request) if proxy else await self.local(request, count) + 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) @@ -480,6 +562,7 @@ async def route(self, request, proxy): "sent", { "function": response.function_code, + "request_id": request_id, "exception": response.exception_code, "registers": response.registers, "bits": response.bits, @@ -579,7 +662,9 @@ def validate_acknowledgement(code, request, response, values): if code == 6 and response.registers != values: raise CommandError("Invalid upstream register acknowledgement") - async def forward(self, request): # pylint: disable=too-many-branches + 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. """ @@ -589,6 +674,14 @@ async def forward(self, request): # pylint: disable=too-many-branches 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( @@ -635,8 +728,25 @@ async def forward(self, request): # pylint: disable=too-many-branches else "acknowledged; no readback" ), ) - return await self.after_response(copy.deepcopy(response)) + 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): @@ -651,6 +761,12 @@ async def forward(self, request): # pylint: disable=too-many-branches 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], diff --git a/src/ctmodbus/server_commands.py b/src/ctmodbus/server_commands.py index bbfa9e9..b048f0b 100644 --- a/src/ctmodbus/server_commands.py +++ b/src/ctmodbus/server_commands.py @@ -37,12 +37,20 @@ "host": Argument(help="Local bind address"), "port": Argument(flags=("--port",)), "foreground": Argument(flags=("--foreground",)), + "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", "foreground") + for name in ( + "baudrate", + "bytesize", + "parity", + "stopbits", + "foreground", + "quiet", + ) }, } @@ -472,7 +480,7 @@ async def connect_data_show(self): ) return heading + "\n" + self.client_state.show(await self.tags.list()) - async def start_server(self, settings, foreground=False, **tls): + async def start_server(self, settings, foreground=False, quiet=False, **tls): """Start in background for TUI, or wait until stopped in explicit foreground mode. """ @@ -480,7 +488,7 @@ async def start_server(self, settings, foreground=False, **tls): config = await self.server_definition() await self.validate_server_tags(config) settings = replace_settings(settings, unit=config["unit"]) - await self.server.start(config, settings, **tls) + await self.server.start(config, settings, quiet=quiet, **tls) except (OSError, ValueError, RuntimeError) as error: raise CommandError(f"Server start failed: {error}") from error if foreground: @@ -492,20 +500,28 @@ async def start_server(self, settings, foreground=False, **tls): @command(name="serve tcp", arguments=NETWORK_ARGS) async def serve_tcp( - self, host: str = "127.0.0.1", port: int = 5020, foreground: bool = False + self, + host: str = "127.0.0.1", + port: int = 5020, + foreground: bool = False, + quiet: bool = False, ): """Serve Modbus TCP; --foreground keeps command-line mode alive.""" return await self.start_server( - ConnectionSettings("tcp", host, port=port), foreground + ConnectionSettings("tcp", host, port=port), foreground, quiet=quiet ) @command(name="serve udp", arguments=NETWORK_ARGS) async def serve_udp( - self, host: str = "127.0.0.1", port: int = 5020, foreground: bool = False + self, + host: str = "127.0.0.1", + port: int = 5020, + foreground: bool = False, + quiet: bool = False, ): """Serve Modbus UDP from the local definition.""" return await self.start_server( - ConnectionSettings("udp", host, port=port), foreground + ConnectionSettings("udp", host, port=port), foreground, quiet=quiet ) @command( @@ -526,11 +542,13 @@ async def serve_tls( key_file: str | None = None, ca_file: str | None = None, foreground: bool = False, + quiet: bool = False, ): """Serve native Modbus TLS; optional CA roots require client certificates.""" return await self.start_server( ConnectionSettings("tls", host, port=port), foreground, + quiet=quiet, cert_file=cert_file, key_file=key_file, ca_file=ca_file, @@ -545,6 +563,7 @@ async def serve_rtu( parity: Literal["N", "E", "O"] = "N", stopbits: int = 1, foreground: bool = False, + quiet: bool = False, ): """Respond as an RTU device on a local serial port, not a traffic tap.""" return await self.start_server( @@ -557,6 +576,7 @@ async def serve_rtu( stopbits=stopbits, ), foreground, + quiet=quiet, ) @command(name="serve ascii", arguments=SERIAL_ARGS) @@ -568,6 +588,7 @@ async def serve_ascii( parity: Literal["N", "E", "O"] = "N", stopbits: int = 1, foreground: bool = False, + quiet: bool = False, ): """Respond as an ASCII device on a local serial port.""" return await self.start_server( @@ -580,6 +601,7 @@ async def serve_ascii( stopbits=stopbits, ), foreground, + quiet=quiet, ) @command(name="serve stop") @@ -594,6 +616,8 @@ async def serve_status(self): mode = "proxy" if self.server.proxy else "local" 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'}" ) @@ -614,8 +638,8 @@ async def serve_data_reset(self, confirm: bool = False): self.server.state.rows.clear() return "Local simulation reset." - @command(name="proxy enable") - async def proxy_enable(self): + @command(name="proxy enable", arguments={"quiet": Argument(flags=("--quiet",))}) + async def proxy_enable(self, quiet: bool = False): """Forward server-unit requests to the connected unit; local rules are bypassed. """ @@ -633,6 +657,7 @@ async def proxy_enable(self): ): 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 disable") @@ -648,4 +673,29 @@ 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}" + return ( + f"Proxy: {mode}\nUpstream: {upstream}\n" + f"Logging: {'on' if self.server.proxy_logging else 'off'}" + ) + + @command( + name="serve logging", + arguments={ + "state": Argument(help="Enable or disable routine server request lines") + }, + ) + def serve_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}.") + + @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/tests/test_server.py b/tests/test_server.py index 1a674ed..9a58e9e 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -611,3 +611,126 @@ async def test_client_state_session_reset_and_write_evidence(self): 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("serve 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("serve logging on") + self.assertIn( + "Request logging: on", (await self.app.dispatch("serve 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"connect tcp 127.0.0.1 --port {port}") + await self.app.dispatch("serve data unit 7") + downstream = free_port() + await self.app.dispatch(f"serve 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 enable") + 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("serve 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 enable --quiet") + await self.app.dispatch("serve 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("serve logging off") + await self.app.dispatch("serve stop") + 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() From ea96a4644b1ec15f584d0c56faff5881ac5aae19 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Tue, 6 Oct 2026 23:14:44 -0600 Subject: [PATCH 27/38] feat: refactor component commands and configuration lifecycle --- AGENTS.md | 9 +- CHANGELOG.md | 11 +- README.md | 101 +++++--- docs/DECISIONS.md | 53 +++++ docs/SERVER.md | 143 +++++++----- docs/STATUS.md | 50 +++- examples/device.toml | 3 +- src/ctmodbus/app.py | 364 +++++++++-------------------- src/ctmodbus/client_commands.py | 397 ++++++++++++++++++++++++++++++++ src/ctmodbus/component_help.py | 79 +++++++ src/ctmodbus/connection.py | 2 +- src/ctmodbus/polling.py | 2 +- src/ctmodbus/proxy_commands.py | 60 +++++ src/ctmodbus/server.py | 6 +- src/ctmodbus/server_commands.py | 200 ++++++---------- src/ctmodbus/server_config.py | 123 +++++++--- src/ctmodbus/tags.py | 22 +- tests/smoke_test.py | 20 +- tests/test_commands.py | 33 +-- tests/test_components.py | 367 +++++++++++++++++++++++++++++ tests/test_integration.py | 4 +- tests/test_polling.py | 12 +- tests/test_server.py | 173 ++++++++------ tests/test_tags.py | 2 +- tests/test_tls.py | 37 +-- tests/test_tui.py | 20 +- 26 files changed, 1641 insertions(+), 652 deletions(-) create mode 100644 src/ctmodbus/client_commands.py create mode 100644 src/ctmodbus/component_help.py create mode 100644 src/ctmodbus/proxy_commands.py create mode 100644 tests/test_components.py diff --git a/AGENTS.md b/AGENTS.md index 3effda5..22d75e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,10 @@ Give every new positional `@command` argument a brief, meaningful 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 @@ -57,8 +61,11 @@ Publishing requires release approval; pushing a version tag triggers publication ## Code map -- `src/ctmodbus/app.py`: lifecycle, connection commands, profiles, project guards, +- `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/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/tags.py`: project tag storage, codecs, commands, and TOML exchange. diff --git a/CHANGELOG.md b/CHANGELOG.md index d974d59..8f5167e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [1.0.0rc1] - Unreleased +- Refactor unreleased commands into client/server/proxy component groups, with + start/stop/status, separate configuration and runtime views, and example + help popups. Remove old command spellings; ctui-owned commands are unchanged. +- Add selected client configuration show/set/save/load, explicit start after + load, atomic editing, and proxy-aware component stop confirmations. +- Require strict TOML format/version markers and actionable unknown-key errors; + add the server format marker and rename hook tick_interval_seconds without + changing server version 1. + - Display correlated server-arrival and proxy-forwarding request lines by default, with per-transport/proxy `--quiet`, independent live logging switches, visible failures, bounded write previews, and full write payloads in server records. @@ -29,7 +38,7 @@ floating-point decoding; configurable byte/word order; tagged reads/writes; and atomic TOML import/export with path completion through `tags export` and `tags import`. -- Add `connect tls` with verified server certificates, custom CA roots, optional +- Add `client start tls` with verified server certificates, custom CA roots, optional client identity, explicit insecure mode, and persisted TLS connection settings. - Migrate from ctui 0.x to ctui 1.0.0rc1 and require Python 3.11+. diff --git a/README.md b/README.md index aff3c66..1989e8d 100644 --- a/README.md +++ b/README.md @@ -43,13 +43,19 @@ Run `ctmodbus` without arguments to open the terminal interface. Commands have automatic completion, generated help, and unique-prefix matching. Use `help` for the full list, including ctui's project, config, and history commands. +Larger commands follow **component → action → transport or target**: +`client start tcp HOST`, `server start tcp HOST`, and `proxy start`. +Bare `client`, `server`, and `proxy` open example help in TUI/WUI popups +and print examples in CLI. `help client`, `help server`, and `help proxy` remain generated references. Old connect/serve/profile/close/cancel +spellings and proxy enable/disable are removed; ordinary unique prefixes remain. + ```text -connect # suggest serial ports and local services -connect tcp 10.10.10.1 --port 502 --unit 1 -connect udp 10.10.10.1 --port 10502 -connect tls plc.example.com --ca-file plant-ca.pem -connect rtu /dev/ttyUSB0 --baudrate 9600 --parity E -connect ascii COM2 --baudrate 9600 +client discover # suggest serial ports and local services +client start tcp 10.10.10.1 --port 502 --unit 1 +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 read id read discrete_inputs 1 read coils 1,3,5,7 @@ -59,7 +65,7 @@ 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 -close +client stop ``` These examples show alternative connections: close the current session before @@ -219,7 +225,7 @@ All completed rows append, including unchanged values. Tag definitions cannot be edited during polling or target preparation. Stop the poll before changing projects; polls never follow a replacement connection. `poll stop` stops future ticks and waits for the active cycle without closing the -connection. `cancel`, `close`, connection loss, or application shutdown stop +connection. `client stop`, connection loss, or application shutdown stop polling; cancellation closes the transport and requires explicit reconnection. ## Connection options and operation results @@ -231,7 +237,7 @@ connections or 1 second for serial connections. Serial defaults are 9600 baud, `--bytesize`, `--parity`, and `--stopbits`. TCP/UDP ports default to 502 and TLS to 802; supply host and port separately, including for IPv6 addresses. -`connect tls HOST` uses Modbus TLS framing and verifies the server certificate +`client start tls HOST` uses Modbus TLS framing and verifies the server certificate against system trust roots and the supplied hostname. Use `--ca-file PATH` for a private CA and `--cert-file PATH --key-file PATH` for client authentication. A certificate file may contain its private key; otherwise supply `--key-file`. @@ -241,10 +247,9 @@ Profiles save these file paths and the verification setting, not certificate or private-key contents; the files must remain available when reconnecting. Device I/O is asynchronous, with requests serialized on the single connection. -The status bar shows the project, transport state, and read progress. `cancel` -or `close` cancels outstanding device work and closes the transport. Reconnect +The status bar shows the project, transport state, and read progress. `client stop` cancels outstanding device work and closes the transport. Reconnect before issuing more requests. The tool does not automatically reconnect. -Ctrl-C clears the input line; use `cancel` to stop running device commands. +Ctrl-C clears the input line; use `client stop` to stop running device commands. Read output includes UTC timestamps and compressed address/value summaries. Register output includes integer, hex, and character views; the character view @@ -257,8 +262,8 @@ The same commands work without opening the terminal UI: ```bash ctmodbus --help -ctmodbus -c 'connect tcp 127.0.0.1 --port 5020' \ - -c 'read holding_registers 0-9' -c 'close' +ctmodbus -c 'client start tcp 127.0.0.1 --port 5020' \ + -c 'read holding_registers 0-9' -c 'client stop' ctmodbus -f commands.txt ``` @@ -282,19 +287,47 @@ records. Live connections are never persisted. project configs list configs show tcp-local -profile connect tcp-local -profile save lab +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 -close +client stop project export lab.ctui-project history export commands.txt project create another-lab project load default -profile connect lab +client config load lab +``` + +`tcp-local` and `udp-local` are built-in localhost profiles. `client config save NAME` +saves selected settings, even while disconnected; `client config load NAME` +validates and selects them without connecting. Use `client start` to connect. +`client config show` displays all selected fields, the source name, and unsaved +changes. `client config set` changes only supplied options, atomically, while +the client is stopped. The first selection requires `--transport` and `--target`: + +```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 +client start ``` -`tcp-local` and `udp-local` are built-in localhost profiles. `profile save NAME` -saves the current settings; `profile connect NAME` validates and loads them. +Settings include transport, target, port, unit, timeout, retries, serial +baudrate/data bits/parity/stop bits, TLS certificate paths, and insecure mode. +Clear TLS paths with `--clear-ca-file`, `--clear-cert-file`, and +`--clear-key-file`; use `--insecure false` to restore verification or +`--insecure true` to disable it. Edits retain omitted values, including when +changing transport, so clear incompatible TLS fields in the same edit. +File contents, live sockets, polling tasks, and runtime evidence are never +saved in client configs. Selection survives client stop and clears on project +transitions. ctui also supplies config JSON import/export and whole-project import/export. Close the connection and finish device work before switching or resetting projects; this keeps protocol records in the project that initiated them. @@ -349,16 +382,16 @@ random and sequence behavior, plus optional Python hooks. Server definitions can be assembled in CTUI or imported/exported as TOML. ```text -serve data import examples/device.toml -serve tcp 127.0.0.1 --port 5020 -serve data show -serve stop -connect tcp 127.0.0.1 --port 502 -connect data show +server config import examples/device.toml +server start tcp 127.0.0.1 --port 5020 +server status +server stop +client start tcp 127.0.0.1 --port 502 +client status ``` -`proxy enable` routes incoming server requests through the connected client; -`proxy disable` restores local behavior. See [server and proxy usage](docs/SERVER.md) +`proxy start` routes incoming server requests through the connected client; +`proxy stop` restores local behavior. See [server and proxy usage](docs/SERVER.md) for all commands, foreground CLI serving, hooks, defaults and evidence semantics. ### Server and proxy request output @@ -372,17 +405,17 @@ operation records retain the full write payload. Arrival/forwarding lines do not imply a successful response or write acknowledgement. ```text -serve tcp 127.0.0.1 --port 5020 --quiet -proxy enable --quiet -serve logging on +server start tcp 127.0.0.1 --port 5020 --quiet +proxy start --quiet +server logging on proxy logging on -serve logging off +server logging off proxy logging off -serve status +server status proxy status ``` -`--quiet` is available on every `serve` transport and on `proxy enable`. Server +`--quiet` is available on every `server start` transport and on `proxy start`. Server arrival and proxy forwarding logging are independent. Live `logging on/off` commands change routine verbosity without stopping the listener or disabling records. Starting a listener or enabling the proxy resets its logging policy diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index a6ed7f1..fa29865 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -331,3 +331,56 @@ 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. diff --git a/docs/SERVER.md b/docs/SERVER.md index 15a709b..842348e 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -1,7 +1,7 @@ # Serving and proxying Modbus -`serve` creates one application-owned listener, independent of the single client -opened by `connect`. Configure the server before starting it. The TUI remains +`server start` creates one application-owned listener, independent of the single client +opened by `client start`. Configure the server before starting it. The TUI remains interactive; stopping the server keeps the client connection open. Exiting shuts both down. Project changes and tag/definition edits require a stopped server. Definitions are saved per project through ctui configs; live observations, tasks, @@ -11,26 +11,26 @@ the definition and tags, but not external Python or certificate files. ## Start and stop ```text -serve data import examples/device.toml -serve data validate -serve tcp 127.0.0.1 --port 5020 -serve status -serve data show -serve stop +server config import examples/device.toml +server config validate +server start tcp 127.0.0.1 --port 5020 +server config show +server status +server stop ``` Other listeners share the same local definition: ```text -serve udp 127.0.0.1 --port 5020 -serve tls 127.0.0.1 --port 8020 --cert-file server.pem --key-file server.key -serve rtu /dev/ttyUSB0 --baudrate 9600 -serve ascii /dev/ttyUSB0 --baudrate 9600 +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. Explicit ports 502/802 work when the operating system permits binding them. -Serial defaults match `connect`: 9600 baud, 8 data bits, no parity, one stop bit. +Serial defaults match `client start`: 9600 baud, 8 data bits, no parity, one stop bit. Serial serving responds as a device, not a passive tap. The client and listener must use different serial ports. Unit IDs 1–247 are supported, defaulting to 1; serial traffic for other units (including broadcasts) is ignored. @@ -42,8 +42,8 @@ The shared dispatch path also supports CLI command sequences. Use explicit ```bash uv run --with-editable ../ctui ctmodbus \ - -c "serve data import examples/device.toml" \ - -c "serve tcp 127.0.0.1 --port 5020 --foreground" + -c "server config import examples/device.toml" \ + -c "server start tcp 127.0.0.1 --port 5020 --foreground" ``` Without foreground mode, a CLI invocation exits after its commands and closes @@ -58,6 +58,14 @@ initialized to false or zero. Defaults are sparse: no giant initial value array. This is permitted by Modbus Application Protocol V1.1b3 sections 4.3/4.4. A request still obeys per-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 top-level/nested fields, and +obsolete `tick_seconds` are rejected with actionable errors. Use +`tick_interval_seconds` for periodic hooks. These unreleased formats have no +legacy migration; update old files explicitly. ctui-owned config fields are +unchanged. + 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 @@ -65,6 +73,7 @@ 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 @@ -134,52 +143,53 @@ writes; replace the rule with static behavior to stop them. ## Assemble a definition in CTUI ```text -serve data clear --confirm -serve data unit 1 -serve data seed 123 -serve data identity --vendor ControlThings --product "Tank and pump simulator" --revision 1.0 -serve data table coils --unmapped illegal -serve data table discrete_inputs --unmapped illegal -serve data table input_registers --unmapped illegal -serve data table holding_registers --unmapped illegal +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 -serve data set tag pump_enabled false -serve data set tag pump_running false -serve data set tag tank_level 100.0 - -serve data set holding_registers 100-199 42 -serve data random holding_registers 7 --min 0 --max 100 -serve data sequence tag tank_level 100,75,50,25 --advance time --interval 1.0 -serve data sequence holding_registers 8 10,20,30 --advance read --repeat=false - -serve hook write examples/device_logic.py on_write -serve hook tick examples/device_logic.py on_tick --interval 1.0 -serve data validate -serve data export device.toml +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. -`serve data remove tag NAME` removes a server tag rule without deleting the -project tag. `serve data remove TABLE RANGE` removes exact configured raw ranges; -the table fallback still applies. `serve hook clear` removes all hooks. +`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. -`serve data import FILE --replace` explicitly approves conflicting project tag +`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. -`serve data validate` checks definitions, shared project tags and referenced +`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. `serve data reset --confirm` restores running local values, +port availability. `server reset --confirm` restores running local values, random state and sequence clocks from the initial definition. Reset is disabled -in proxy mode. Raw data and configured initial values remain distinct. +in proxy mode. Reset preserves saved settings and timestamped observations, +including earlier proxy evidence. Raw evidence and initial values remain distinct. ## Optional Python hooks @@ -192,7 +202,7 @@ module = "device_logic.py" on_read = "on_read" on_write = "on_write" on_tick = "on_tick" -tick_seconds = 1.0 +tick_interval_seconds = 1.0 ``` All hooks are optional and share one Python file. The module path resolves @@ -229,7 +239,7 @@ 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 `serve status` and operation +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. @@ -237,16 +247,23 @@ rule files are not implemented. ## Proxying and evidence views ```text -connect rtu /dev/ttyUSB0 --baudrate 9600 --retries 0 -serve tcp 127.0.0.1 --port 5020 -proxy enable +client start rtu /dev/ttyUSB0 --baudrate 9600 --retries 0 +server start tcp 127.0.0.1 --port 5020 +proxy start proxy status -connect data show -serve data show -proxy disable -serve stop +client status +server status +proxy stop +server stop ``` +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. + Proxying requires both endpoints. 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 @@ -257,18 +274,18 @@ 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 enable requires `--retries 0`. Closing the client -cancels/drains active exchanges; proxy mode remains enabled and fails clearly -until reconnection or `proxy disable`. Obvious loops back to the listener are -rejected. Proxy disable routes new requests locally; already-routed exchanges +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. -`connect data show` works without a server, accumulating successful console +`client status` works without a server, accumulating successful console reads and attempted/acknowledged/uncertain writes. It includes the last observed -device identification. `serve data show` shows configured initial rules, current -local static tags and last downstream observations. Proxy upstream values are +device identification. `server config show` shows saved initial rules; +`server status` shows current local static tags and last downstream observations. Proxy upstream values are recorded before the response extension point; downstream values afterwards. -The two views are independent. Neither show command issues network requests. +The two views are independent. 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 @@ -281,9 +298,9 @@ only request/response extension seams exist today. ## Request display controls Listeners default to appending request-arrival lines; proxy mode additionally -appends forwarding lines with the same request ID. `--quiet` on any `serve` -transport suppresses routine server lines. `proxy enable --quiet` independently -suppresses routine forwarding lines. `serve logging on/off` and +appends forwarding lines with the same request ID. `--quiet` on any `server start` +transport suppresses routine server lines. `proxy start --quiet` independently +suppresses routine forwarding lines. `server logging on/off` and `proxy logging on/off` update these policies live, and status commands show them. Errors bypass both quiet settings; operation recording is independent of display. Request IDs increase across listener restarts within an application, and verbosity diff --git a/docs/STATUS.md b/docs/STATUS.md index ea9b892..5b9a67b 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,7 +1,7 @@ # Project status -Last reconciled: 2026-10-06, `dev-v1.0.0` at `aad20cd`, with local server/proxy -request logging, quiet controls, and shared runtime output appends. +Last reconciled: 2026-10-06, `dev-v1.0.0` at `5ffde8c`, with the local +component command/configuration/TOML refactor. Changes remain uncommitted. ## Current state @@ -44,12 +44,12 @@ request logging, quiet controls, and shared runtime output appends. 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 cancel/close/shutdown stop polling. See + 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 enable` support `--quiet`; independent - live `serve logging` / `proxy logging` switches suppress routine lines while + 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](SERVER.md#request-display-controls) and @@ -58,6 +58,20 @@ request logging, quiet controls, and shared runtime output appends. command output follows existing per-command behavior; runtime request/polling lines explicitly append. ctui built-in commands remain unchanged. +- 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. + ## Next steps and validation gaps 1. Multi-tag serialization/partial output and tag-import confirmation, atomic @@ -65,7 +79,9 @@ request logging, quiet controls, and shared runtime output appends. 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. Exercise tag completion, collision confirmation, and typed output in a real +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 @@ -85,6 +101,28 @@ release readiness lacks final-revision and manual/remote validation. ## Validation evidence +- 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. diff --git a/examples/device.toml b/examples/device.toml index 6bf85a3..77dab77 100644 --- a/examples/device.toml +++ b/examples/device.toml @@ -1,4 +1,5 @@ # ctmodbus pump/tank server configuration. +format = "ctmodbus-server" version = 1 unit = 1 @@ -51,4 +52,4 @@ value = 100.0 module = "device_logic.py" on_write = "on_write" on_tick = "on_tick" -tick_seconds = 1.0 +tick_interval_seconds = 1.0 diff --git a/src/ctmodbus/app.py b/src/ctmodbus/app.py index 42252ba..5298c1f 100644 --- a/src/ctmodbus/app.py +++ b/src/ctmodbus/app.py @@ -10,59 +10,30 @@ import sys from dataclasses import replace from importlib.metadata import version -from typing import Literal -from ctui import ( - Argument, - CommandError, - CommandResult, - ConfirmationRequired, - CtuiApp, - command, -) +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.discovery import complete_serial, suggestions from ctmodbus.operations import ModbusCommandMixin from ctmodbus.polling import TABLE_ARGUMENTS, Poll, PollCommandMixin +from ctmodbus.proxy_commands import ProxyCommandMixin from ctmodbus.server import Server -from ctmodbus.server_commands import CONFIG_NAME, ServerCommandMixin +from ctmodbus.server_commands import ServerCommandMixin from ctmodbus.tags import TagCommandMixin, TagStore, parse_tag_document -NETWORK_ARGUMENTS = { - name: Argument(flags=(f"--{name}",)) - for name in ("port", "unit", "timeout", "retries") -} -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(completer=complete_serial) - - -async def complete_profiles(context): - """Complete saved profile names from the active project.""" - return list(await context.app.configs.list()) - -class ModbusApp( # pylint: disable=too-many-public-methods - PollCommandMixin, ServerCommandMixin, TagCommandMixin, ModbusCommandMixin, CtuiApp +class ModbusApp( # pylint: disable=too-many-public-methods,too-many-ancestors + ComponentHelpMixin, + ClientCommandMixin, + ProxyCommandMixin, + PollCommandMixin, + ServerCommandMixin, + TagCommandMixin, + ModbusCommandMixin, + CtuiApp, ): """A Modbus client with one connection and project-scoped services. @@ -88,6 +59,11 @@ def __init__(self, *, client_factory=create_client, **kwargs): 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) @@ -229,33 +205,77 @@ async def dispatch( # pylint: disable=too-many-branches,too-many-statements "project reset", } device_command = item.name.startswith( - ("connect ", "read ", "write ", "profile ") + ("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 == "serve data import" + server_import = item.name == "server config import" server_start = item.name in { - "serve tcp", - "serve udp", - "serve tls", - "serve rtu", - "serve ascii", + "server start tcp", + "server start udp", + "server start tls", + "server start rtu", + "server start ascii", } server_edit = ( ( - item.name.startswith("serve data ") + item.name.startswith("server config ") and item.name not in { - "serve data show", - "serve data export", - "serve data validate", - "serve data reset", + "server config show", + "server config export", + "server config validate", + "server reset", } ) - or item.name.startswith("serve hook ") + 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" @@ -315,12 +335,12 @@ async def dispatch( # pylint: disable=too-many-branches,too-many-statements "A project transition is in progress; retry when it finishes" ) opening = item.name in { - "connect tcp", - "connect tls", - "connect udp", - "connect rtu", - "connect ascii", - "profile connect", + "client start tcp", + "client start tls", + "client start udp", + "client start rtu", + "client start ascii", + "client start", } if device_command: if self._opening: @@ -351,7 +371,22 @@ async def dispatch( # pylint: disable=too-many-branches,too-many-statements 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 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: @@ -369,6 +404,9 @@ async def dispatch( # pylint: disable=too-many-branches,too-many-statements 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) @@ -390,6 +428,10 @@ async def dispatch( # pylint: disable=too-many-branches,too-many-statements ) 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 @@ -495,7 +537,12 @@ async def on_stop(self): self._stopping = True pending = self._tag_mutations | { task - for task in (self._tag_import_task, self._server_edit_task) + for task in ( + self._tag_import_task, + self._server_edit_task, + self._client_config_task, + self._component_stop_task, + ) if task is not None } pending.discard(asyncio.current_task()) @@ -505,194 +552,3 @@ async def on_stop(self): await asyncio.wait_for(asyncio.gather(*pending, return_exceptions=True), 5) await self.server.stop() await self.close_connection() - - @command(name="connect") - async def connect_suggestions(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") - 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="connect tcp", arguments=NETWORK_ARGUMENTS) - async def connect_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="connect tls", arguments=TLS_ARGUMENTS) - async def connect_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="connect udp", arguments=NETWORK_ARGUMENTS) - async def connect_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="connect rtu", arguments=SERIAL_ARGUMENTS) - async def connect_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="connect ascii", arguments=SERIAL_ARGUMENTS) - async def connect_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 - async def close(self): - """Close the session and cancel outstanding device operations.""" - await self.close_connection() - return CommandResult.append("Session CLOSED") - - @command - async def cancel(self): - """Cancel device work and close the connection to discard pending replies.""" - await self.close_connection() - return CommandResult.append("Device work cancelled; session CLOSED") - - @command(name="profile save") - async def profile_save(self, name: str): - """Save current settings under NAME and return an appended confirmation. - - Existing configs are replaced. Missing settings raise CommandError; - storage errors propagate. Profiles contain certificate paths, not keys - or certificate contents, and depend on those paths on the next machine. - """ - if name == CONFIG_NAME: - raise CommandError("That name is reserved for server configuration") - if self.connection.settings is None: - raise CommandError("Connect before saving a profile") - await self.configs.save(name, self.connection.settings.as_dict()) - return CommandResult.append(f"Saved connection profile {name!r}") - - @command( - name="profile connect", - arguments={"name": Argument(completer=complete_profiles)}, - ) - async def profile_connect(self, name: str): - """Load NAME, validate ConnectionSettings, and return open_connection result. - - Missing or invalid profiles raise CommandError before device I/O; - otherwise open_connection owns setup and failure cleanup. - """ - values = await self.configs.get(name) - try: - settings = ConnectionSettings(**values) - except (TypeError, ValueError) as error: - raise CommandError( - f"Invalid connection profile {name!r}: {error}" - ) from error - return await self.open_connection(settings) 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/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 index da8f18f..16c3d61 100644 --- a/src/ctmodbus/connection.py +++ b/src/ctmodbus/connection.py @@ -230,7 +230,7 @@ async def operation(self): """ client, generation = self.client, self.generation if client is None: - raise CommandError("No open session; connect first") + raise CommandError("No open session; use client start first") task = asyncio.current_task() self.tasks.add(task) try: diff --git a/src/ctmodbus/polling.py b/src/ctmodbus/polling.py index a7a0879..c9f5161 100644 --- a/src/ctmodbus/polling.py +++ b/src/ctmodbus/polling.py @@ -164,7 +164,7 @@ async def start(self, columns, interval, count, duration): 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; connect first") + 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 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/server.py b/src/ctmodbus/server.py index 16fb0b2..56da076 100644 --- a/src/ctmodbus/server.py +++ b/src/ctmodbus/server.py @@ -216,7 +216,7 @@ def server_tls(cert_file, key_file, ca_file): certificates. """ if not cert_file or not key_file: - raise CommandError("serve tls requires --cert-file and --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: @@ -273,7 +273,7 @@ async def start( """ async with self.lifecycle: if self.listener is not None: - raise CommandError("Server already running; use serve stop first") + 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 @@ -395,7 +395,7 @@ async def ticks(self): """ previous = self.simulator.clock() while True: - await asyncio.sleep(self.config["hooks"].get("tick_seconds", 1)) + await asyncio.sleep(self.config["hooks"].get("tick_interval_seconds", 1)) now = self.simulator.clock() elapsed, previous = now - previous, now if self.proxy: diff --git a/src/ctmodbus/server_commands.py b/src/ctmodbus/server_commands.py index b048f0b..a4ccc86 100644 --- a/src/ctmodbus/server_commands.py +++ b/src/ctmodbus/server_commands.py @@ -27,7 +27,7 @@ from ctmodbus.simulation import Simulator from ctmodbus.tags import decode_tag_value, encode_tag_value, parse_tag_value -CONFIG_NAME = "ctmodbus-server" +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"), @@ -96,10 +96,10 @@ async def prepare_server_import(self, text, kwargs): return None @command( - name="serve data import", + name="server config import", arguments={**PATH_ARG, "replace": Argument(flags=("--replace",))}, ) - async def serve_data_import(self, path: Path, replace: bool = False): + 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: @@ -134,16 +134,17 @@ async def serve_data_import(self, path: Path, replace: bool = False): await self.backend.touch() return f"Imported server definition with {len(tags)} tags." - @command(name="serve data export", arguments=PATH_ARG) - async def serve_data_export(self, path: Path): + @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="serve data clear", arguments={"confirm": Argument(flags=("--confirm",))} + name="server config clear", + arguments={"confirm": Argument(flags=("--confirm",))}, ) - async def serve_data_clear(self, confirm: bool = False): + 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") @@ -153,10 +154,10 @@ async def serve_data_clear(self, confirm: bool = False): ) @command( - name="serve data unit", + name="server config unit", arguments={"unit": Argument(help="Server unit ID, 1–247")}, ) - async def serve_data_unit(self, unit: int): + 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 @@ -164,10 +165,10 @@ async def serve_data_unit(self, unit: int): return f"Server unit set to {unit}." @command( - name="serve data seed", + name="server config seed", arguments={"seed": Argument(help="Reproducible random seed")}, ) - async def serve_data_seed(self, seed: int): + 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 @@ -175,13 +176,13 @@ async def serve_data_seed(self, seed: int): return f"Server seed set to {seed}." @command( - name="serve data identity", + name="server config identity", arguments={ name: Argument(flags=("--" + name,)) for name in ("vendor", "product", "revision") }, ) - async def serve_data_identity( + async def server_config_identity( self, vendor: str = "ControlThings", product: str = "ctmodbus simulator", @@ -198,14 +199,14 @@ async def serve_data_identity( return "Server identification updated." @command( - name="serve data table", + name="server config table", arguments={ "table": Argument(help="Modbus table"), "unmapped": Argument(flags=("--unmapped",)), "default": Argument(flags=("--default",)), }, ) - async def serve_data_table( + async def server_config_table( self, table: Literal[ "coils", "discrete_inputs", "input_registers", "holding_registers" @@ -274,24 +275,24 @@ async def set_server_rule(self, target, selection, rule): return f"Set {rule['mode']} behavior for {target} {selection}." @command( - name="serve data set", + name="server config set", arguments={**TARGET_ARGS, "value": Argument(help="Initial scalar value")}, ) - async def serve_data_set(self, target: str, selection: str, value: str): + 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="serve data random", + name="server config random", arguments={ **TARGET_ARGS, "minimum": Argument(flags=("--min",)), "maximum": Argument(flags=("--max",)), }, ) - async def serve_data_random( + 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 @@ -302,7 +303,7 @@ async def serve_data_random( ) @command( - name="serve data sequence", + name="server config sequence", arguments={ **TARGET_ARGS, "values": Argument(help="Comma-separated sequence values"), @@ -311,7 +312,7 @@ async def serve_data_random( "repeat": Argument(flags=("--repeat",)), }, ) - async def serve_data_sequence( + async def server_config_sequence( self, target: str, selection: str, @@ -343,48 +344,48 @@ async def set_server_hook(self, name, module, function, interval=None): ) config["hooks"].update(module=path, **{name: function}) if interval is not None: - config["hooks"]["tick_seconds"] = interval + config["hooks"]["tick_interval_seconds"] = interval await self.save_server_definition(config) return f"Configured {name}: {function}." @command( - name="serve hook read", + name="server hook read", arguments={ "module": Argument(help="Companion Python file", completer=PathCompleter()), "function": Argument(help="Read hook function name"), }, ) - async def serve_hook_read(self, module: Path, function: str): + 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="serve hook write", + name="server hook write", arguments={ "module": Argument(help="Companion Python file", completer=PathCompleter()), "function": Argument(help="Write hook function name"), }, ) - async def serve_hook_write(self, module: Path, function: str): + 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="serve hook tick", + 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 serve_hook_tick(self, module: Path, function: str, interval: float = 1): + 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="serve hook clear") - async def serve_hook_clear(self): + @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"] = {} @@ -401,8 +402,8 @@ async def validate_server_tags(self, config): "remove or reconfigure its rule" ) - @command(name="serve data remove", arguments=TARGET_ARGS) - async def serve_data_remove(self, target: str, selection: str): + @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": @@ -425,8 +426,8 @@ async def serve_data_remove(self, target: str, selection: str): await self.save_server_definition(config) return "Server rules removed." - @command(name="serve data validate") - async def serve_data_validate(self): + @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) @@ -436,21 +437,14 @@ async def serve_data_validate(self): "Listener binding and hook behavior are not tested." ) - @command(name="serve data show") - async def serve_data_show(self): - """Display committed state without observing a hook's pending transaction.""" - async with self.server.request_lock: - return await self.show_server_data() + @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 definition, local tag state and downstream observations without - device I/O. - """ - config = await self.server_definition() - lines = [ - "Server definition (initial values / rules):", - server_config.dumps(config), - ] + """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):" @@ -468,18 +462,6 @@ async def show_server_data(self): lines.append(self.server.state.show(await self.tags.list())) return "\n".join(lines) - @command(name="connect data show") - async def connect_data_show(self): - """Show last read/write evidence, without issuing requests or treating - writes as readback. - """ - heading = ( - "Connected" - if self.connection.connected - else "Disconnected; last session evidence retained" - ) - return heading + "\n" + self.client_state.show(await self.tags.list()) - async def start_server(self, settings, foreground=False, quiet=False, **tls): """Start in background for TUI, or wait until stopped in explicit foreground mode. @@ -498,8 +480,8 @@ async def start_server(self, settings, foreground=False, quiet=False, **tls): await self.server.stop() return CommandResult.append(f"Server {self.server.label}") - @command(name="serve tcp", arguments=NETWORK_ARGS) - async def serve_tcp( + @command(name="server start tcp", arguments=NETWORK_ARGS) + async def server_start_tcp( self, host: str = "127.0.0.1", port: int = 5020, @@ -511,8 +493,8 @@ async def serve_tcp( ConnectionSettings("tcp", host, port=port), foreground, quiet=quiet ) - @command(name="serve udp", arguments=NETWORK_ARGS) - async def serve_udp( + @command(name="server start udp", arguments=NETWORK_ARGS) + async def server_start_udp( self, host: str = "127.0.0.1", port: int = 5020, @@ -525,7 +507,7 @@ async def serve_udp( ) @command( - name="serve tls", + name="server start tls", arguments={ **NETWORK_ARGS, **{ @@ -534,7 +516,7 @@ async def serve_udp( }, }, ) - async def serve_tls( + async def server_start_tls( self, host: str = "127.0.0.1", port: int = 8020, @@ -554,8 +536,8 @@ async def serve_tls( ca_file=ca_file, ) - @command(name="serve rtu", arguments=SERIAL_ARGS) - async def serve_rtu( + @command(name="server start rtu", arguments=SERIAL_ARGS) + async def server_start_rtu( self, device: str, baudrate: int = 9600, @@ -579,8 +561,8 @@ async def serve_rtu( quiet=quiet, ) - @command(name="serve ascii", arguments=SERIAL_ARGS) - async def serve_ascii( + @command(name="server start ascii", arguments=SERIAL_ARGS) + async def server_start_ascii( self, device: str, baudrate: int = 9600, @@ -604,29 +586,31 @@ async def serve_ascii( quiet=quiet, ) - @command(name="serve stop") - async def serve_stop(self): - """Stop the listener and drain local/proxy work; keep the client connection.""" + @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="serve status") - async def serve_status(self): + @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'}" + f"Last error: {self.server.last_error or 'none'}\n{data}" ) - @command( - name="serve data reset", arguments={"confirm": Argument(flags=("--confirm",))} - ) - async def serve_data_reset(self, confirm: bool = False): + @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. + evidence alone. Last observations stay timestamped historical evidence. """ if not confirm: raise CommandError("Use --confirm to reset local simulation state") @@ -635,67 +619,15 @@ async def serve_data_reset(self, confirm: bool = False): async with self.server.request_lock: current = self.server.simulator self.server.simulator = Simulator(current.config, current.hooks) - self.server.state.rows.clear() return "Local simulation reset." - @command(name="proxy enable", arguments={"quiet": Argument(flags=("--quiet",))}) - async def proxy_enable(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 disable") - async def proxy_disable(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="serve logging", + name="server logging", arguments={ "state": Argument(help="Enable or disable routine server request lines") }, ) - def serve_logging(self, state: Literal["on", "off"]): + 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}.") - - @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/server_config.py b/src/ctmodbus/server_config.py index 41845ac..dcfedd6 100644 --- a/src/ctmodbus/server_config.py +++ b/src/ctmodbus/server_config.py @@ -15,7 +15,9 @@ from ctui import CommandError -from ctmodbus.tags import TABLES, Tag, encode_tag_value +from ctmodbus.tags import TABLES, TAG_FIELDS, Tag, encode_tag_value + +CONFIG_NAME = "ctmodbus-server" IDENTITY = { "vendor": "ControlThings", @@ -28,6 +30,7 @@ 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), @@ -85,9 +88,11 @@ def validate_rule(rule, table, tag=None): "random": {"min", "max"}, "sequence": {"values", "advance", "interval_seconds", "repeat"}, }.get(mode, set()) - unknown = set(rule) - allowed - if mode not in MODES or unknown: - raise CommandError(f"Invalid {mode!r} rule fields: {sorted(unknown)}") + 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 @@ -125,30 +130,57 @@ def validate_rule(rule, table, tag=None): raise CommandError("Read sequences do not accept interval_seconds") -def validate(config): # pylint: disable=too-many-branches - """Return normalized settings; reject unknown fields, spans, and rule overlaps.""" +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: - if not isinstance(config, dict) or set(config) - { - "version", - "unit", - "identity", - "tables", - "tags", - "hooks", - "seed", - }: - raise CommandError("Unknown server configuration fields") + 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("version", 1)) is not int - or config.get("version", 1) != 1 - or type(config.get("unit", 1)) is not int + type(config.get("unit", 1)) is not int or not 1 <= config.get("unit", 1) <= 247 ): - raise CommandError("Require version 1 and unit ID 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() @@ -158,8 +190,13 @@ def validate(config): # pylint: disable=too-many-branches "up to 200 UTF-8 bytes" ) result["identity"] = identity - if set(result["tables"]) - set(TABLES): - raise CommandError("Unknown Modbus table") + 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", @@ -175,8 +212,12 @@ def validate(config): # pylint: disable=too-many-branches ] 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 @@ -194,12 +235,23 @@ def validate(config): # pylint: disable=too-many-branches 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 {"table", "address", "type", "byte_order", "word_order"} - } + 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] @@ -208,12 +260,17 @@ def validate(config): # pylint: disable=too-many-branches spans[tag.table].append((tag.address, tag.stop)) validate_rule(rule, tag.table, tag) hooks = result["hooks"] - if set(hooks) - {"module", "on_read", "on_write", "on_tick", "tick_seconds"}: - raise CommandError("Unknown emulator hook") + 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_seconds" + if k != "tick_interval_seconds" ): raise CommandError("Hook paths and function names must be nonempty strings") if any( @@ -221,13 +278,13 @@ def validate(config): # pylint: disable=too-many-branches ) and not hooks.get("module"): raise CommandError("Hooks require a Python module path") if "on_tick" in hooks: - interval = hooks.get("tick_seconds", 1) + 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_seconds must be positive and finite") + 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 @@ -239,7 +296,7 @@ def load(path): try: config = tomllib.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError) as error: - raise CommandError(f"Cannot import server data: {error}") from error + raise CommandError(f"Cannot import server configuration: {error}") from error config = validate(config) if config["hooks"].get("module"): config["hooks"]["module"] = str( diff --git a/src/ctmodbus/tags.py b/src/ctmodbus/tags.py index eb0b6c5..4888592 100644 --- a/src/ctmodbus/tags.py +++ b/src/ctmodbus/tags.py @@ -33,6 +33,8 @@ from ctmodbus.formatting import timestamp +TAG_FIELDS = {"table", "address", "type", "byte_order", "word_order"} + TABLES = ("coils", "discrete_inputs", "input_registers", "holding_registers") CREATE_TABLES = { "coil": "coils", @@ -431,8 +433,16 @@ def parse_tag_document(path): document = tomllib.load(stream) except (OSError, tomllib.TOMLDecodeError) as error: raise CommandError(f"Cannot import tags: {error}") from error - if document.get("format") != "ctmodbus-tags" or document.get("version") != 1: - raise CommandError("Unsupported tag export format") + 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") @@ -440,13 +450,7 @@ def parse_tag_document(path): 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) - { - "table", - "address", - "type", - "byte_order", - "word_order", - } + unknown = set(values) - TAG_FIELDS if unknown: raise CommandError( f"Tag {name!r} has unknown fields: {', '.join(sorted(unknown))}" diff --git a/tests/smoke_test.py b/tests/smoke_test.py index 80dfefb..0fdc652 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -22,18 +22,30 @@ def main(): ["ctmodbus", "--help"], check=True, capture_output=True, text=True ) assert "read" in result.stdout - assert "profile" in result.stdout - assert "serve" 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 profile", "-c", "help serve"], + [ + "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", "connect", "tcp", "rtu", "data"): + for command in ("holding_registers", "start", "config", "status", "tcp", "rtu"): assert command in groups.stdout app = ModbusApp(data_dir=directory) status = asyncio.run( diff --git a/tests/test_commands.py b/tests/test_commands.py index db243d1..dceed4f 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -121,16 +121,16 @@ async def asyncTearDown(self): async def connect(self): """Open the test TCP session using the injected fake client.""" - await self.app.dispatch("connect tcp localhost --unit 7") + 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"connect {transport} target --unit 7 --timeout 0.2" + f"client start {transport} target --unit 7 --timeout 0.2" ) self.assertEqual(self.app.connection.settings.transport, transport) - await self.app.dispatch("close") + 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.""" @@ -280,7 +280,7 @@ async def test_cancel_read_and_queued_work(self): 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("cancel"), 1) + 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) @@ -293,7 +293,7 @@ async def test_cancel_write_reports_uncertainty(self): 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("close") + await self.app.dispatch("client stop") with self.assertRaisesRegex(CommandError, "outcome unknown"): await task @@ -307,9 +307,9 @@ async def blocked(): await asyncio.Event().wait() self.client.connect = blocked - task = asyncio.create_task(self.app.dispatch("connect tcp localhost")) + task = asyncio.create_task(self.app.dispatch("client start tcp localhost")) await started.wait() - await asyncio.wait_for(self.app.dispatch("cancel"), 1) + await asyncio.wait_for(self.app.dispatch("client stop"), 1) with self.assertRaises(CommandError): await task self.assertIsNone(self.app.connection.client) @@ -336,7 +336,7 @@ async def test_failed_connect_cleanup(self): async def test_double_connect_rejected(self): """Require explicit close before opening another session.""" await self.connect() - with self.assertRaisesRegex(CommandError, "already open"): + with self.assertRaisesRegex(CommandError, "Stop the client"): await self.connect() async def test_disconnected_session(self): @@ -349,7 +349,7 @@ async def test_disconnected_session(self): 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("profile save lab") + 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) @@ -360,12 +360,13 @@ async def test_profiles_records_history_and_project_isolation(self): ): with self.assertRaises(CommandError): await self.app.dispatch(text) - await self.app.dispatch("close") + 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("profile connect lab") + 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]) @@ -382,7 +383,7 @@ async def slow_start(*args, **kwargs): return await original(*args, **kwargs) with patch.object(self.app.records, "start_session", slow_start): - task = asyncio.create_task(self.app.dispatch("connect tcp localhost")) + 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") @@ -410,7 +411,7 @@ async def test_cli_command_file_and_argument_failure(self): with tempfile.TemporaryDirectory() as path: script = Path(path) / "commands.txt" script.write_text( - "# local fixture\n\nconnect tcp localhost\nread coils 0\n", + "# local fixture\n\nclient start tcp localhost\nread coils 0\n", encoding="utf-8", ) app = ModbusApp( @@ -424,7 +425,7 @@ async def test_cli_command_file_and_argument_failure(self): self.assertEqual(status, 0, err.getvalue()) self.assertIn("Write acknowledged", out.getvalue()) status = await app.run_cli( - ["-c", "connect tcp localhost", "-c", "write coils 0 nope"], + ["-c", "client start tcp localhost", "-c", "write coils 0 nope"], stdout=out, stderr=err, ) @@ -450,7 +451,7 @@ async def test_invalid_profile(self): "bad", {"transport": "tcp", "target": "localhost", "timeout": "oops"} ) with self.assertRaises(CommandError): - await self.app.dispatch("profile connect bad") + 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.""" @@ -472,7 +473,7 @@ async def test_cli_sequence_and_cleanup(self): status = await app.run_cli( [ "-c", - "connect tcp localhost", + "client start tcp localhost", "-c", "read coils 0", "-c", diff --git a/tests/test_components.py b/tests/test_components.py new file mode 100644 index 0000000..6769a9d --- /dev/null +++ b/tests/test_components.py @@ -0,0 +1,367 @@ +"""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("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" + ) + await self.app.dispatch("client start tcp localhost") + self.app.server.proxy = True + with patch.object(view, "dialog", AsyncMock(return_value={"button": 1})): + await 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.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_integration.py b/tests/test_integration.py index 0d1b4d5..14ed1ae 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -54,7 +54,7 @@ async def run_network(self, transport): await app.backend.open() try: await app.dispatch( - f"connect {transport} 127.0.0.1 --port {port} --timeout 0.5" + f"client start {transport} 127.0.0.1 --port {port} --timeout 0.5" ) await self.exercise(app) finally: @@ -104,7 +104,7 @@ def forward(source, destination): await app.backend.open() try: await app.dispatch( - f"connect {transport} {os.ttyname(slave2)} --timeout 1" + f"client start {transport} {os.ttyname(slave2)} --timeout 1" ) await self.exercise(app) finally: diff --git a/tests/test_polling.py b/tests/test_polling.py index 5da35f4..e2cb69b 100644 --- a/tests/test_polling.py +++ b/tests/test_polling.py @@ -32,7 +32,7 @@ async def asyncSetUp(self): data_dir=self.directory.name, client_factory=lambda settings: self.client ) await self.app.backend.open() - await self.app.dispatch("connect tcp localhost") + await self.app.dispatch("client start tcp localhost") async def asyncTearDown(self): """Drain polling and device work before closing isolated project storage.""" @@ -154,15 +154,15 @@ async def test_graceful_stop_and_abort_idle_or_inflight(self): self.client.gate = None await self.app.dispatch("poll raw --coils 0 --interval 60") await asyncio.sleep(0.02) - await self.app.dispatch("cancel") + await self.app.dispatch("client stop") self.assertFalse(self.app.poller.active) self.assertFalse(self.client.connected) - await self.app.dispatch("connect tcp localhost") + 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("close"), 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)) @@ -245,7 +245,7 @@ async def paused(): await self.app.dispatch(text) release.set() await starting - await self.app.dispatch("close") + await self.app.dispatch("client stop") self.assertFalse(self.client.connected) self.assertFalse(self.app.poller.active) @@ -261,7 +261,7 @@ async def test_cli_lifecycle_and_web_runtime(self): result = await app.run_cli( [ "-c", - "connect tcp localhost", + "client start tcp localhost", "-c", "poll raw --coils 0 --count 2 --interval 0.01", "-c", diff --git a/tests/test_server.py b/tests/test_server.py index 9a58e9e..fe6e64a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -59,6 +59,7 @@ def test_full_defaults_and_sparse_roundtrip(self): self.assertEqual(load(path), config) sparse = validate( { + "format": "ctmodbus-server", "version": 1, "tables": {"coils": {"ranges": [{"start": 7, "value": True}]}}, } @@ -79,10 +80,18 @@ def test_invalid_spans_and_rules(self): }, ): with self.subTest(rule=rule), self.assertRaises(CommandError): - validate({"tables": {"holding_registers": {"ranges": [rule]}}}) + 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", @@ -90,7 +99,7 @@ def test_invalid_spans_and_rules(self): "type": "float32", "value": 1.0, } - } + }, } ) @@ -200,7 +209,7 @@ async def asyncTearDown(self): async def client(self, transport="tcp"): port = free_port(transport == "udp") - await self.app.dispatch(f"serve {transport} 127.0.0.1 --port {port}") + 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) @@ -230,29 +239,29 @@ async def test_tcp_and_udp_boundaries_and_identity(self): ) result = await client.read_device_information(device_id=1) self.assertEqual(result.information[0], b"ControlThings") - await self.app.dispatch("serve stop") + await self.app.dispatch("server stop --confirm") async def test_ui_configuration_export_and_import_confirmation(self): for text in ( - "serve data clear --confirm", + "server config clear --confirm", "tags create level input_register 10 float32", - "serve data set tag level 12.5", - "serve data set holding_registers 100-199 42", - "serve data sequence holding_registers 7 10,20,30 --advance read", - "serve data table coils --unmapped default --default true", + "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"serve data export {path}") + await self.app.dispatch(f"server config export {path}") original = await self.app.server_definition() - await self.app.dispatch("serve data clear --confirm") - await self.app.dispatch(f"serve data import {path}") + await self.app.dispatch("server config clear --confirm") + await self.app.dispatch(f"server config import {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"serve data import {path}") + await self.app.dispatch(f"server config import {path}") async def approve(message): path.write_text(dumps(definition(False))) @@ -260,16 +269,18 @@ async def approve(message): await self.app.dispatch("project create other") return True - await self.app.dispatch(f"serve data import {path}", confirm_callback=approve) + await self.app.dispatch( + f"server config import {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("serve data clear --confirm") + await self.app.dispatch("server config clear --confirm") await self.app.dispatch( - "serve data sequence holding_registers 7 10,20 --advance read" + "server config sequence holding_registers 7 10,20 --advance read" ) client = await self.client() self.assertEqual( @@ -288,13 +299,23 @@ async def test_sparse_map_dynamic_rules_and_lifecycle_guards(self): ) for text in ( "project create other", - "serve data clear --confirm", + "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("serve data show")).output) - await self.app.dispatch("serve data reset --confirm") + 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], @@ -305,13 +326,23 @@ async def test_proxy_tcp_to_udp_and_client_observations(self): await upstream.serve_forever(background=True) port = upstream.transport.get_extra_info("sockname")[1] try: - await self.app.dispatch(f"connect udp 127.0.0.1 --port {port}") + 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("connect data show")).output) - await self.app.dispatch("serve data clear --confirm") - await self.app.dispatch("serve data unit 7") + 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 enable") + 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) @@ -333,16 +364,19 @@ async def test_proxy_tcp_to_udp_and_client_observations(self): ).registers, [55], ) - for text in ("serve data show", "connect data show"): + for text in ("server status", "client status"): self.assertIn("55", (await self.app.dispatch(text)).output) - await self.app.dispatch("proxy disable") + 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("serve stop") + 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() @@ -352,9 +386,9 @@ async def test_proxy_failure_never_falls_back_and_state_views_differ(self): await upstream.serve_forever(background=True) port = upstream.transport.sockets[0].getsockname()[1] try: - await self.app.dispatch(f"connect tcp 127.0.0.1 --port {port}") + await self.app.dispatch(f"client start tcp 127.0.0.1 --port {port}") client = await self.client() - await self.app.dispatch("proxy enable") + await self.app.dispatch("proxy start") async def change(response): response.registers = [80] @@ -369,13 +403,18 @@ async def change(response): self.assertEqual( self.app.server.state.rows["holding_registers", 0]["read"], 80 ) - await self.app.dispatch("close") + 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() @@ -384,20 +423,20 @@ async def test_hook_file_failure_rolls_back_and_tick_changes_values(self): 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("serve data clear --confirm") + 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("serve data set tag enable false") - await self.app.dispatch("serve data set tag running false") - await self.app.dispatch(f"serve hook write {path} on_write") - await self.app.dispatch(f"serve hook tick {path} on_tick --interval 0.02") - await self.app.dispatch("serve data validate") + 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 {path} on_write") + await self.app.dispatch(f"server hook tick {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("serve status")).output + "intentional failure", (await self.app.dispatch("server status")).output ) async def tick_done(): @@ -411,7 +450,7 @@ async def tick_done(): (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"serve data export {path_export}") + await self.app.dispatch(f"server config export {path_export}") self.assertEqual(load(path_export)["hooks"]["module"], str(path)) async def test_raw_invalid_count_and_pipelined_transactions(self): @@ -445,10 +484,10 @@ async def test_bind_failure_and_foreground_cleanup(self): occupied.bind(("127.0.0.1", port)) occupied.listen() with self.assertRaises(CommandError): - await self.app.dispatch(f"serve tcp 127.0.0.1 --port {port}") + await self.app.dispatch(f"server start tcp 127.0.0.1 --port {port}") self.assertIsNone(self.app.server.listener) task = asyncio.create_task( - self.app.dispatch(f"serve tcp 127.0.0.1 --port {port} --foreground") + self.app.dispatch(f"server start tcp 127.0.0.1 --port {port} --foreground") ) async def started(): @@ -457,7 +496,7 @@ async def started(): await asyncio.wait_for(started(), 1) self.assertFalse(task.done()) - await self.app.dispatch("serve stop") + await self.app.dispatch("server stop --confirm") await asyncio.wait_for(task, 1) self.assertIsNone(self.app.server.listener) @@ -491,7 +530,7 @@ async def test_native_tls_and_client_certificate_requirement(self): ) port = free_port() await self.app.dispatch( - f"serve tls 127.0.0.1 --port {port} --cert-file {cert} --key-file {key}" + f"server start tls 127.0.0.1 --port {port} --cert-file {cert} --key-file {key}" ) context = ssl.create_default_context(cafile=str(cert)) client = AsyncModbusTlsClient( @@ -504,9 +543,9 @@ async def test_native_tls_and_client_certificate_requirement(self): [0], ) client.close() - await self.app.dispatch("serve stop") + await self.app.dispatch("server stop --confirm") await self.app.dispatch( - f"serve tls 127.0.0.1 --port {port} --cert-file {cert} --key-file {key} --ca-file {cert}" + f"server start tls 127.0.0.1 --port {port} --cert-file {cert} --key-file {key} --ca-file {cert}" ) context.load_cert_chain(str(cert), str(key)) client = AsyncModbusTlsClient( @@ -540,7 +579,7 @@ def forward(source, destination): loop.add_reader(second, forward, second, first) peer = None try: - await self.app.dispatch(f"serve {transport} {os.ttyname(a)}") + 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, @@ -561,15 +600,15 @@ def forward(source, destination): peer.close() peer = None # Turn the serial peer into an upstream device and proxy TCP to it. - await self.app.dispatch("serve stop") + 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"connect {transport} {os.ttyname(b)} --timeout 1" + f"client start {transport} {os.ttyname(b)} --timeout 1" ) downstream = await self.client() - await self.app.dispatch("proxy enable") + await self.app.dispatch("proxy start") self.assertEqual( ( await downstream.read_holding_registers( @@ -578,8 +617,8 @@ def forward(source, destination): ).registers, [17], ) - await self.app.dispatch("serve stop") - await self.app.dispatch("close") + await self.app.dispatch("server stop --confirm") + await self.app.dispatch("client stop --confirm") finally: await upstream.shutdown() finally: @@ -594,17 +633,17 @@ def forward(source, destination): 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"connect tcp 127.0.0.1 --port {port}") + 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("close") - await self.app.dispatch(f"connect tcp 127.0.0.1 --port {port}") + 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("close") + await self.app.dispatch("client stop --confirm") with self.assertRaises(CommandError): await self.app.dispatch("write holding_registers 3 99") self.assertEqual( @@ -622,7 +661,7 @@ async def test_request_lines_quiet_switches_and_records(self): 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("serve logging off") + 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") @@ -639,9 +678,9 @@ async def test_request_lines_quiet_switches_and_records(self): ) self.assertEqual(len(records), 3) self.assertEqual([row.decoded["request_id"] for row in records], [1, 2, 3]) - await self.app.dispatch("serve logging on") + await self.app.dispatch("server logging on") self.assertIn( - "Request logging: on", (await self.app.dispatch("serve status")).output + "Request logging: on", (await self.app.dispatch("server status")).output ) async def test_proxy_lines_independent_quiet_and_failure(self): @@ -650,22 +689,24 @@ async def test_proxy_lines_independent_quiet_and_failure(self): await upstream.serve_forever(background=True) port = upstream.transport.sockets[0].getsockname()[1] try: - await self.app.dispatch(f"connect tcp 127.0.0.1 --port {port}") - await self.app.dispatch("serve data unit 7") + 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"serve tcp 127.0.0.1 --port {downstream} --quiet") + 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 enable") + 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("serve logging on") + 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) @@ -677,8 +718,8 @@ async def test_proxy_lines_independent_quiet_and_failure(self): 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 enable --quiet") - await self.app.dispatch("serve logging off") + 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) @@ -704,8 +745,8 @@ async def test_log_preview_limits_escaping_and_restart_defaults(self): ) 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("serve logging off") - await self.app.dispatch("serve stop") + await self.app.dispatch("server logging off") + await self.app.dispatch("server stop --confirm") await self.client() self.assertTrue(self.app.server.request_logging) diff --git a/tests/test_tags.py b/tests/test_tags.py index 2b62dae..2e94d27 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -104,7 +104,7 @@ async def asyncTearDown(self): async def connect(self): """Open the test TCP session using the injected fake client.""" - await self.app.dispatch("connect tcp localhost") + 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.""" diff --git a/tests/test_tls.py b/tests/test_tls.py index 06caf26..52292d4 100644 --- a/tests/test_tls.py +++ b/tests/test_tls.py @@ -26,25 +26,28 @@ class TlsOptionsTests(unittest.IsolatedAsyncioTestCase): asyncTearDown = test_commands.CommandTests.asyncTearDown async def test_defaults_and_profile_roundtrip(self): - """Preserve TLS verification and certificate paths across profile save/connect.""" - await self.app.dispatch("connect tls example.com") + """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("close") + await self.app.dispatch("client stop") await self.app.dispatch( - 'connect tls example.com --port 1802 --ca-file "root ca.pem" --cert-file client.pem --key-file client.key --insecure' + '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("profile save secure") + await self.app.dispatch("client config save secure") settings = self.app.connection.settings - await self.app.dispatch("close") - await self.app.dispatch("profile connect secure") + 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("connect tls example.com --key-file client.key") + 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.""" @@ -58,7 +61,9 @@ async def delayed(*args, **kwargs): return await original(*args, **kwargs) with patch.object(self.app.records, "start_session", delayed): - task = asyncio.create_task(self.app.dispatch("connect tls example.com")) + 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") @@ -131,7 +136,7 @@ async def asyncTearDown(self): async def test_verified_tls_operations(self): """Verify the shared protocol workflow over a trusted TLS connection.""" await self.app.dispatch( - f'connect tls localhost --port {self.port} --ca-file "{self.cert}"' + f'client start tls localhost --port {self.port} --ca-file "{self.cert}"' ) await test_integration.TransportTests.exercise(self, self.app) @@ -139,7 +144,7 @@ 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"connect tls localhost --port {self.port} --timeout 0.5" + f"client start tls localhost --port {self.port} --timeout 0.5" ) self.assertIsNone(self.app.connection.client) @@ -147,13 +152,15 @@ 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'connect tls 127.0.0.1 --port {self.port} --ca-file "{self.cert}" --timeout 0.5' + 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"connect tls 127.0.0.1 --port {self.port} --insecure") + 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) @@ -161,7 +168,7 @@ 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'connect tls localhost --port {self.port} --ca-file "{self.cert}.missing"' + f'client start tls localhost --port {self.port} --ca-file "{self.cert}.missing"' ) self.assertIsNone(self.app.connection.client) @@ -180,7 +187,7 @@ async def test_client_certificate(self): await self.server.serve_forever(background=True) port = self.server.transport.sockets[0].getsockname()[1] await self.app.dispatch( - f'connect tls localhost --port {port} --ca-file "{self.cert}" --cert-file "{self.cert}" --key-file "{self.key}"' + 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) diff --git a/tests/test_tui.py b/tests/test_tui.py index 9609d91..c1a51f2 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -3,6 +3,7 @@ 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 @@ -39,7 +40,7 @@ async def on_ready(self): try: await asyncio.wait_for(ready.wait(), 3) for text, name in ( - ("connect tcp localhost", "connect tcp"), + ("client start tcp localhost", "client start tcp"), ("read coils 0-2", "read coils"), ("write coils 0 1", "write coils"), ): @@ -47,6 +48,23 @@ async def on_ready(self): self.assertEqual( await asyncio.wait_for(finished.get(), 3), name ) + 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" From f0cdb2318acfd7dae121bd5266f94a40837efcb4 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Tue, 6 Oct 2026 23:47:56 -0600 Subject: [PATCH 28/38] feat: add management popups and automatic CLI server keepalive --- AGENTS.md | 1 + CHANGELOG.md | 6 + README.md | 24 ++- docs/DECISIONS.md | 30 ++++ docs/SERVER.md | 18 ++- docs/STATUS.md | 38 ++++- src/ctmodbus/app.py | 99 ++++++++++-- src/ctmodbus/commands.py | 8 +- src/ctmodbus/result_popups.py | 67 +++++++++ src/ctmodbus/server_commands.py | 27 +--- tests/test_components.py | 7 +- tests/test_result_popups.py | 256 ++++++++++++++++++++++++++++++++ tests/test_server.py | 17 +-- tests/test_tui.py | 6 + 14 files changed, 537 insertions(+), 67 deletions(-) create mode 100644 src/ctmodbus/result_popups.py create mode 100644 tests/test_result_popups.py diff --git a/AGENTS.md b/AGENTS.md index 22d75e8..0b7e4bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,6 +65,7 @@ Publishing requires release approval; pushing a version tag triggers publication 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f5167e..efb215b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [1.0.0rc1] - Unreleased +- Present ctmodbus management results in standard scrollable TUI/WUI popups, + preserving main output while device work and request logging continue. +- Remove server --foreground options. CLI now runs the full command sequence, + then keeps remaining listeners alive; errors, explicit exit, and interruption + clean up server and client resources. + - Refactor unreleased commands into client/server/proxy component groups, with start/stop/status, separate configuration and runtime views, and example help popups. Remove old command spellings; ctui-owned commands are unchanged. diff --git a/README.md b/README.md index 1989e8d..23b269f 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,20 @@ poll before changing projects; polls never follow a replacement connection. connection. `client stop`, connection loss, or application shutdown stop polling; cancellation closes the transport and requires explicit reconnection. +## Management result popups + +In TUI/WUI, ctmodbus management results use standard scrollable message dialogs +with descriptive titles. This includes start/stop, list/show/status, import/export, +discovery, validation, configuration set/save/load, tag create/rename/delete, +server rule/hook edits, logging switches, reset, and clear. Actions finish before +the dialog opens. Dismiss it before submitting another command; running servers, +proxying, polling, and request logging continue. Main output remains intact. +Existing confirmation dialogs still precede actions requiring approval. + +Read/write results, polling rows, and server/proxy request logs stay in the main +output pane. Bare component help keeps its standard help popup. Inherited ctui +commands retain their existing behavior. CLI management results remain plain text. + ## Connection options and operation results All connections accept `--unit` (1–247), `--timeout` (seconds), and `--retries` @@ -274,7 +288,11 @@ interactive examples above are explanatory and should be omitted when typing. Commands execute sequentially in CLI mode and stop at the first error. Exit status is 0 for success, 2 for command/argument/protocol errors, and 1 for -unexpected command failures. Shutdown closes the connection automatically. +unexpected command failures. After a successful CLI command sequence, the process stays alive if a server +is still running. Ctrl-C stops it and closes both server and client. Include +`server stop` to finish a temporary-server sequence and exit normally. An error +or explicit exit command skips keepalive and shuts everything down. Server starts +return immediately to allow subsequent commands; `--foreground` is removed. ## Projects, profiles, and records @@ -392,7 +410,7 @@ client status `proxy start` routes incoming server requests through the connected client; `proxy stop` restores local behavior. See [server and proxy usage](docs/SERVER.md) -for all commands, foreground CLI serving, hooks, defaults and evidence semantics. +for all commands, CLI server keepalive, hooks, defaults and evidence semantics. ### Server and proxy request output @@ -430,6 +448,6 @@ Request failures remain visible in quiet mode. Failed proxy writes distinguish potentially unconfirmed outcomes; upstream exceptions are shown without implying forwarding success. Display escapes control characters. Polling and server logs share one synchronous append path for terminal, browser, and CLI output. CLI -listeners still need `--foreground` to remain running. Malformed/unsupported +listeners remain running automatically after a successful command sequence. Malformed/unsupported requests that the decoder reports also produce diagnostic lines. Future MITM logging is not implemented. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index fa29865..46ea0d5 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -384,3 +384,33 @@ 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. diff --git a/docs/SERVER.md b/docs/SERVER.md index 842348e..681a06a 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -37,19 +37,23 @@ serial traffic for other units (including broadcasts) is ignored. TLS uses native PyModbus TLS framing. Certificate and unencrypted key files are required. `--ca-file` additionally requires trusted client certificates. -The shared dispatch path also supports CLI command sequences. Use explicit -`--foreground` to keep a listener alive in CLI mode: +The shared dispatch path supports CLI command sequences. Every server start +returns immediately. After all commands succeed, CLI stays alive if a listener +remains running: ```bash uv run --with-editable ../ctui ctmodbus \ -c "server config import examples/device.toml" \ - -c "server start tcp 127.0.0.1 --port 5020 --foreground" + -c "server start tcp 127.0.0.1 --port 5020" ``` -Without foreground mode, a CLI invocation exits after its commands and closes -its listener. In a TUI, use the normal background form. Foreground commands -wait until stopped or interrupted; do not place later commands behind them in -a command file. +Ctrl-C stops the listener and client cleanly. Commands following a server start +still run before keepalive. A sequence ending with server stop exits normally; +command errors or explicit exit shut down immediately. The previous +`--foreground` option is removed from every transport and interface. +TUI/WUI starts display their completed startup result in a standard message +popup while the listener and request logs remain active. Management popups +preserve the main output; see [result presentation](../README.md#management-result-popups). ## Address maps and values diff --git a/docs/STATUS.md b/docs/STATUS.md index 5b9a67b..1c04633 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,7 +1,7 @@ # Project status -Last reconciled: 2026-10-06, `dev-v1.0.0` at `5ffde8c`, with the local -component command/configuration/TOML refactor. Changes remain uncommitted. +Last reconciled: 2026-10-06, `dev-v1.0.0` at `ea96a46`, with local management +result popups and automatic CLI server keepalive. Changes remain uncommitted. ## Current state @@ -55,8 +55,9 @@ component command/configuration/TOML refactor. Changes remain uncommitted. [request display controls](SERVER.md#request-display-controls) and [D15](DECISIONS.md#d15--independent-server-and-proxy-request-display). - The `aad20cd` revert removed the application-wide append default. Ordinary - command output follows existing per-command behavior; runtime request/polling - lines explicitly append. ctui built-in commands remain unchanged. + 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 @@ -72,6 +73,17 @@ component command/configuration/TOML refactor. Changes remain uncommitted. 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). + ## Next steps and validation gaps 1. Multi-tag serialization/partial output and tag-import confirmation, atomic @@ -101,6 +113,24 @@ release readiness lacks final-revision and manual/remote validation. ## Validation evidence +- 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 diff --git a/src/ctmodbus/app.py b/src/ctmodbus/app.py index 5298c1f..edb04a8 100644 --- a/src/ctmodbus/app.py +++ b/src/ctmodbus/app.py @@ -20,6 +20,7 @@ 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.tags import TagCommandMixin, TagStore, parse_tag_document @@ -71,6 +72,11 @@ def __init__(self, *, client_factory=create_client, **kwargs): 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 = {} @@ -179,8 +185,49 @@ async def prepare_tag_import(self, text, kwargs): ) 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 + ): + 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, result.output) + finally: + self._result_popup_task = None + finally: + self._result_popup_tasks.discard(task) + return replace(result, output=None, append_output=False) + return result + # Lifecycle arbitration is intentionally centralized around command dispatch. - async def dispatch( # pylint: disable=too-many-branches,too-many-statements + 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. @@ -515,12 +562,15 @@ async def close_connection(self): self.update_progress(0, 0) async def run_cli(self, arguments, *, stdout=None, stderr=None, program=None): - """Stream polling rows to CLI stdout and await limits before later commands. + """Run CLI commands, then keep a remaining server alive until stopped. - TUI/WUI starts remain background operations. An unlimited CLI poll runs - until interrupted. Delegate parsing and backend lifecycle to ctui. + 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 @@ -533,18 +583,37 @@ async def on_start(self): self._stopping = False async def on_stop(self): - """Reject new dispatches and await close_connection before backend closure.""" + """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 | { - task - for task in ( - self._tag_import_task, - self._server_edit_task, - self._client_config_task, - self._component_stop_task, - ) - if task is not None - } + 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() diff --git a/src/ctmodbus/commands.py b/src/ctmodbus/commands.py index eb84907..bbf096b 100644 --- a/src/ctmodbus/commands.py +++ b/src/ctmodbus/commands.py @@ -12,9 +12,13 @@ def main(): 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. No live application is retained globally. + returns after exit. Ctrl-C completes async cleanup and exits 130 without a + traceback. No live application is retained globally. """ - ModbusApp().run() + try: + ModbusApp().run() + except KeyboardInterrupt: + raise SystemExit(130) from None if __name__ == "__main__": 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_commands.py b/src/ctmodbus/server_commands.py index a4ccc86..412f75f 100644 --- a/src/ctmodbus/server_commands.py +++ b/src/ctmodbus/server_commands.py @@ -36,7 +36,6 @@ NETWORK_ARGS = { "host": Argument(help="Local bind address"), "port": Argument(flags=("--port",)), - "foreground": Argument(flags=("--foreground",)), "quiet": Argument(flags=("--quiet",)), } SERIAL_ARGS = { @@ -48,7 +47,6 @@ "bytesize", "parity", "stopbits", - "foreground", "quiet", ) }, @@ -462,10 +460,8 @@ async def show_server_data(self): lines.append(self.server.state.show(await self.tags.list())) return "\n".join(lines) - async def start_server(self, settings, foreground=False, quiet=False, **tls): - """Start in background for TUI, or wait until stopped in explicit - foreground mode. - """ + 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) @@ -473,11 +469,6 @@ async def start_server(self, settings, foreground=False, quiet=False, **tls): await self.server.start(config, settings, quiet=quiet, **tls) except (OSError, ValueError, RuntimeError) as error: raise CommandError(f"Server start failed: {error}") from error - if foreground: - try: - await self.server.stop_event.wait() - finally: - await self.server.stop() return CommandResult.append(f"Server {self.server.label}") @command(name="server start tcp", arguments=NETWORK_ARGS) @@ -485,12 +476,11 @@ async def server_start_tcp( self, host: str = "127.0.0.1", port: int = 5020, - foreground: bool = False, quiet: bool = False, ): - """Serve Modbus TCP; --foreground keeps command-line mode alive.""" + """Start a Modbus TCP listener without blocking later commands.""" return await self.start_server( - ConnectionSettings("tcp", host, port=port), foreground, quiet=quiet + ConnectionSettings("tcp", host, port=port), quiet=quiet ) @command(name="server start udp", arguments=NETWORK_ARGS) @@ -498,12 +488,11 @@ async def server_start_udp( self, host: str = "127.0.0.1", port: int = 5020, - foreground: bool = False, quiet: bool = False, ): """Serve Modbus UDP from the local definition.""" return await self.start_server( - ConnectionSettings("udp", host, port=port), foreground, quiet=quiet + ConnectionSettings("udp", host, port=port), quiet=quiet ) @command( @@ -523,13 +512,11 @@ async def server_start_tls( cert_file: str | None = None, key_file: str | None = None, ca_file: str | None = None, - foreground: bool = False, quiet: bool = False, ): """Serve native Modbus TLS; optional CA roots require client certificates.""" return await self.start_server( ConnectionSettings("tls", host, port=port), - foreground, quiet=quiet, cert_file=cert_file, key_file=key_file, @@ -544,7 +531,6 @@ async def server_start_rtu( bytesize: int = 8, parity: Literal["N", "E", "O"] = "N", stopbits: int = 1, - foreground: bool = False, quiet: bool = False, ): """Respond as an RTU device on a local serial port, not a traffic tap.""" @@ -557,7 +543,6 @@ async def server_start_rtu( parity=parity, stopbits=stopbits, ), - foreground, quiet=quiet, ) @@ -569,7 +554,6 @@ async def server_start_ascii( bytesize: int = 8, parity: Literal["N", "E", "O"] = "N", stopbits: int = 1, - foreground: bool = False, quiet: bool = False, ): """Respond as an ASCII device on a local serial port.""" @@ -582,7 +566,6 @@ async def server_start_ascii( parity=parity, stopbits=stopbits, ), - foreground, quiet=quiet, ) diff --git a/tests/test_components.py b/tests/test_components.py index 6769a9d..1026508 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -283,14 +283,15 @@ async def test_browser_overviews_and_stop_confirmations(self): self.assertEqual( self.app.layout.output_field.text, "Existing output" ) - await self.app.dispatch("client start tcp localhost") + 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.command("client stop", "decline") + 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.command("client stop", "approve") + await view.spawn(view.command("client stop", "approve")) self.assertFalse(self.client.connected) self.assertFalse(self.app.server.proxy) finally: diff --git a/tests/test_result_popups.py b/tests/test_result_popups.py new file mode 100644 index 0000000..5500e54 --- /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", + "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 index fe6e64a..297b4ab 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -478,7 +478,7 @@ async def packet(): writer.close() await writer.wait_closed() - async def test_bind_failure_and_foreground_cleanup(self): + async def test_bind_failure_and_nonblocking_start(self): port = free_port() with socket.socket() as occupied: occupied.bind(("127.0.0.1", port)) @@ -486,18 +486,13 @@ async def test_bind_failure_and_foreground_cleanup(self): with self.assertRaises(CommandError): await self.app.dispatch(f"server start tcp 127.0.0.1 --port {port}") self.assertIsNone(self.app.server.listener) - task = asyncio.create_task( - self.app.dispatch(f"server start tcp 127.0.0.1 --port {port} --foreground") + result = await asyncio.wait_for( + self.app.dispatch(f"server start tcp 127.0.0.1 --port {port}"), 1 ) - - async def started(): - while self.app.server.label == "Stopped": - await asyncio.sleep(0.01) - - await asyncio.wait_for(started(), 1) - self.assertFalse(task.done()) + 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") - await asyncio.wait_for(task, 1) self.assertIsNone(self.app.server.listener) @unittest.skipUnless(shutil.which("openssl"), "TLS certificates require OpenSSL") diff --git a/tests/test_tui.py b/tests/test_tui.py index c1a51f2..de3dac6 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -48,6 +48,12 @@ async def on_ready(self): 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): From 5db3c8ecb9b2f308c68ae7c212f356cd30ec60b6 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Wed, 7 Oct 2026 00:24:14 -0600 Subject: [PATCH 29/38] feat: add confirmed bulk tag deletion --- AGENTS.md | 1 + CHANGELOG.md | 5 + README.md | 32 ++++- docs/DECISIONS.md | 24 ++++ docs/SERVER.md | 8 ++ docs/STATUS.md | 31 ++++- src/ctmodbus/app.py | 46 +++++-- src/ctmodbus/tag_deletion.py | 109 +++++++++++++++ src/ctmodbus/tags.py | 75 +++++++++- tests/smoke_test.py | 2 +- tests/test_components.py | 2 +- tests/test_result_popups.py | 2 +- tests/test_tag_deletion.py | 256 +++++++++++++++++++++++++++++++++++ 13 files changed, 570 insertions(+), 23 deletions(-) create mode 100644 src/ctmodbus/tag_deletion.py create mode 100644 tests/test_tag_deletion.py diff --git a/AGENTS.md b/AGENTS.md index 0b7e4bf..5ed1e17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,6 +69,7 @@ Publishing requires release approval; pushing a version tag triggers publication - `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. diff --git a/CHANGELOG.md b/CHANGELOG.md index efb215b..28650e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [1.0.0rc1] - Unreleased +- Add confirmed `tags delete --all` and explicit `--remove-server-rules` for + single/bulk deletion. Remove tags and referenced server tag entries atomically, + preserve other configuration, warn about hook references, and retain standard + Yes/No approval and MessageDialog results. + - Present ctmodbus management results in standard scrollable TUI/WUI popups, preserving main output while device work and request logging continue. - Remove server --foreground options. CLI now runs the full command sequence, diff --git a/README.md b/README.md index 23b269f..6a0c4e8 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,34 @@ Concurrent exports still assume sequential use. See the [documented concurrency limitations](docs/DECISIONS.md#d12--implementation-discrepancies-and-ctui-proposals) before embedding concurrent tag-management or project-switching commands. +### Removing tags + +Delete one tag with `tags delete NAME`, or all project tags with +`tags delete --all`. The server and polling must be stopped. Bulk deletion +requires a Yes/No confirmation in TUI/WUI, or explicit `--confirm` in CLI; +an empty project reports "No tags to delete" without prompting. A name and +`--all` cannot be combined. + +Saved server tag entries prevent deletion by default. The error lists referenced +tags; add `--remove-server-rules` to remove those entries in the same atomic +transaction. This also works for one tag and requires approval. Raw ranges, +table defaults, identity, unit/seed settings and hooks stay unchanged. If hooks +exist, the confirmation warns that Python references may need manual updates; +companion scripts are never inspected or rewritten. Hook warnings also require +approval for a single unreferenced tag. + +```text +tags delete temperature +tags delete temperature --remove-server-rules --confirm +tags delete --all +tags delete --all --remove-server-rules --confirm +``` + +Declining or cancelling approval changes nothing. Failures within the deletion transaction roll back +both tag removal and server configuration changes. Completion uses the existing +result MessageDialog in TUI/WUI. Exported TOML files remain unchanged; importing +one later can recreate its tags. + ## Polling Start one poll in the TUI or WUI; commands remain available while it runs. @@ -303,8 +331,8 @@ records. Live connections are never persisted. ```text project -configs list -configs show tcp-local +project configs list +project configs show tcp-local client config load tcp-local client config show client config set --unit 7 --timeout 0.5 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 46ea0d5..f34382f 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -414,3 +414,27 @@ 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/SERVER.md b/docs/SERVER.md index 681a06a..a42b67c 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -195,6 +195,14 @@ 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. +Tag deletion refuses names referenced by saved server tag entries. Use +`tags delete NAME --remove-server-rules` or +`tags delete --all --remove-server-rules` to remove both atomically, after +confirmation (CLI requires `--confirm`). The server and polling must be stopped. +Raw table rules, fallback settings and hooks are preserved; the confirmation +warns about retained hooks that might reference deleted names. See +[tag removal](../README.md#removing-tags). + ## Optional Python hooks The [pump example](../examples/device.toml) references a companion diff --git a/docs/STATUS.md b/docs/STATUS.md index 1c04633..c09b1ce 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,7 +1,7 @@ # Project status -Last reconciled: 2026-10-06, `dev-v1.0.0` at `ea96a46`, with local management -result popups and automatic CLI server keepalive. Changes remain uncommitted. +Last reconciled: 2026-10-07, `dev-v1.0.0` at `f0cdb23`, with local confirmed +bulk tag deletion and atomic server tag-rule removal. Changes remain uncommitted. ## Current state @@ -84,6 +84,15 @@ result popups and automatic CLI server keepalive. Changes remain uncommitted. 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 @@ -113,6 +122,24 @@ release readiness lacks final-revision and manual/remote validation. ## Validation evidence +- 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, diff --git a/src/ctmodbus/app.py b/src/ctmodbus/app.py index edb04a8..a85d8e3 100644 --- a/src/ctmodbus/app.py +++ b/src/ctmodbus/app.py @@ -23,6 +23,7 @@ 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 @@ -32,6 +33,7 @@ class ModbusApp( # pylint: disable=too-many-public-methods,too-many-ancestors ProxyCommandMixin, PollCommandMixin, ServerCommandMixin, + TagDeletionMixin, TagCommandMixin, ModbusCommandMixin, CtuiApp, @@ -83,6 +85,8 @@ def __init__(self, *, client_factory=create_client, **kwargs): 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 = {} @@ -212,20 +216,24 @@ async def dispatch(self, text, **kwargs): and self._poll_stdout is None and getattr(self, "app", None) is not None ): - 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, result.output) - finally: - self._result_popup_task = None - finally: - self._result_popup_tasks.discard(task) + 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 @@ -332,6 +340,13 @@ async def dispatch_modbus( # pylint: disable=too-many-branches,too-many-stateme "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: @@ -402,6 +417,8 @@ async def dispatch_modbus( # pylint: disable=too-many-branches,too-many-stateme 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: @@ -423,6 +440,10 @@ async def dispatch_modbus( # pylint: disable=too-many-branches,too-many-stateme 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) @@ -487,6 +508,9 @@ async def dispatch_modbus( # pylint: disable=too-many-branches,too-many-stateme 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 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 index 4888592..0b75730 100644 --- a/src/ctmodbus/tags.py +++ b/src/ctmodbus/tags.py @@ -240,6 +240,50 @@ async def delete(self, 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. @@ -601,11 +645,32 @@ async def tag_rename(self, name: str, new_name: str): 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(completer=complete_tags)}) - async def tag_delete(self, name: str): - """Delete a tag from the active project.""" - await self.tags.delete(name) - return CommandResult.append(f"Deleted tag {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", diff --git a/tests/smoke_test.py b/tests/smoke_test.py index 0fdc652..9f21586 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -49,7 +49,7 @@ def main(): assert command in groups.stdout app = ModbusApp(data_dir=directory) status = asyncio.run( - app.run_cli(["-c", "project", "-c", "configs show tcp-local"]) + app.run_cli(["-c", "project", "-c", "project configs show tcp-local"]) ) assert status == 0 print("Installed distribution smoke test passed") diff --git a/tests/test_components.py b/tests/test_components.py index 1026508..c76e264 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -190,7 +190,7 @@ async def test_overviews_generated_help_and_no_old_commands(self): with self.assertRaises(CommandError): await self.app.dispatch(old) self.assertEqual(self.app.commands.resolve("serve")[0].name, "server") - await self.app.dispatch("configs show tcp-local") + 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) diff --git a/tests/test_result_popups.py b/tests/test_result_popups.py index 5500e54..af75834 100644 --- a/tests/test_result_popups.py +++ b/tests/test_result_popups.py @@ -121,7 +121,7 @@ def test_scope_and_descriptive_titles(self): "write tag", "poll tags", "poll raw", - "configs show", + "project configs show", "history export", "project import", "client", 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() From 9c15d086d00479d695fbc1f10a007e109d0f6d38 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Fri, 23 Oct 2026 19:43:12 -0600 Subject: [PATCH 30/38] feat: support radix and signed register write lists --- CHANGELOG.md | 4 ++ README.md | 13 ++++++- docs/DECISIONS.md | 9 +++++ docs/STATUS.md | 26 ++++++++++++- src/ctmodbus/integers.py | 24 ++++++++++++ src/ctmodbus/operations.py | 38 +++++++++++++++++-- src/ctmodbus/tags.py | 28 ++------------ tests/test_commands.py | 75 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 186 insertions(+), 31 deletions(-) create mode 100644 src/ctmodbus/integers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 28650e9..ffaf721 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [1.0.0rc1] - Unreleased +- Accept mixed decimal and `0x`/`0b`/`0o`/`0d` literals in address-based + holding-register write lists, including signed 16-bit values encoded as + two's complement. Preserve unsigned 0–65535 values and single-request limits. + - Add confirmed `tags delete --all` and explicit `--remove-server-rules` for single/bulk deletion. Remove tags and referenced server tag entries atomically, preserve other configuration, warn about hook references, and retain standard diff --git a/README.md b/README.md index 6a0c4e8..ff5550b 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ 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 stop ``` @@ -81,7 +83,16 @@ repetitions. A read accepts at most 65,536 addresses including repetitions. `--max-count` limits each request, not the total number of addresses: up to 2,000 for bits and 125 for registers. Use `50-58` to read nine addresses at 50. -Writes accept comma-separated integers: 0/1 for coils and 0–65535 for registers. +Writes accept comma-separated integers: 0/1 for coils and -32768–65535 for +holding registers. Each register value occupies one 16-bit register; negative +values use two's complement (`-1` writes `0xFFFF`). Register values accept +decimal and `0x`/`0b`/`0o`/`0d` prefixes, optional signs, and underscores, +using the same integer literal syntax as tags. Prefixes may be mixed in one +list: `0x8000,0b0100110,0d800` writes 32768, 38, and 800 to consecutive +addresses. Use `--` before a list starting with a negative value. Omit spaces +around commas or quote the entire list. Results and records show unsigned +wire values. Use typed tags for floating-point or multi-register values. + One value selects a single write; multiple values select one multiple-write request (at most 1,968 coils or 123 registers). Writes never split automatically. Success means the device returned a matching acknowledgement, not an independent diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index f34382f..e45454b 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -54,6 +54,15 @@ 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`. diff --git a/docs/STATUS.md b/docs/STATUS.md index c09b1ce..1de50fd 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,10 +1,18 @@ # Project status -Last reconciled: 2026-10-07, `dev-v1.0.0` at `f0cdb23`, with local confirmed -bulk tag deletion and atomic server tag-rule removal. Changes remain uncommitted. +Last reconciled: 2026-10-23, `dev-v1.0.0` at `5db3c8e`, with local radix and +signed 16-bit address-based register writes. These changes remain uncommitted; +the prior bulk tag deletion work is committed at `5db3c8e`. ## Current state +- `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). @@ -122,6 +130,20 @@ release readiness lacks final-revision and manual/remote validation. ## Validation evidence +- 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 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 index 42c1266..d52dfaa 100644 --- a/src/ctmodbus/operations.py +++ b/src/ctmodbus/operations.py @@ -17,6 +17,7 @@ ) from ctmodbus.formatting import format_identification, format_values, timestamp +from ctmodbus.integers import parse_integer READ_LIMITS = { "coils": 2000, @@ -65,6 +66,22 @@ def validate_write(kind, address, 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. @@ -367,7 +384,20 @@ 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") - async def write_holding_registers(self, address: int, values: list[int]): - """Write one or more registers as comma-separated integers.""" - return await self.write_values("holding_registers", 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/tags.py b/src/ctmodbus/tags.py index 0b75730..19f8efa 100644 --- a/src/ctmodbus/tags.py +++ b/src/ctmodbus/tags.py @@ -32,6 +32,7 @@ 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"} @@ -58,10 +59,6 @@ } TYPES = ("bool", *TYPE_FORMATS) NAME_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_.-]*\Z") -INTEGER_PATTERN = re.compile( - r"(?P[+-]?)(?:(?P0[bBoOdDxX])(?P[0-9A-Fa-f_]+)|" - r"(?P[0-9][0-9_]*))\Z" -) @dataclass(frozen=True) @@ -351,28 +348,11 @@ async def import_all(self, tags, *, replace=False): await self.backend.touch() -def _parse_integer(text): - """Return a signed integer from decimal or 0b/0o/0d/0x text. - - Strip underscores from matched digits and accept an optional leading sign. - Malformed text or digits invalid for the radix raise ValueError. Width and - two's-complement interpretation are handled by callers. - """ - 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 - - 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 + 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 @@ -398,11 +378,11 @@ def parse_tag_value(tag, text): try: value = float(text.replace("_", "")) except ValueError: - value = float(_parse_integer(text)) + value = float(parse_integer(text)) if not math.isfinite(value): raise ValueError("non-finite values are not supported") return value - value = _parse_integer(text) + value = parse_integer(text) if tag.type.startswith("int"): match = INTEGER_PATTERN.fullmatch(text) prefix = (match.group("prefix") or "0d").lower() diff --git a/tests/test_commands.py b/tests/test_commands.py index dceed4f..6e68460 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -175,6 +175,81 @@ async def test_write_functions_and_acknowledgements(self): ["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() From 1fda698f40bfa3470eb36ce97b4d933c153a7d1d Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Fri, 23 Oct 2026 23:42:33 -0600 Subject: [PATCH 31/38] fix: require stable ctui 1.0.0 --- AGENTS.md | 18 +- CHANGELOG.md | 3 + README.md | 25 +- docs/DECISIONS.md | 6 + docs/STATUS.md | 61 ++-- pyproject.toml | 2 +- uv.lock | 745 +++++++++++++++++++++++++++++++++++++++++++++- 7 files changed, 806 insertions(+), 54 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5ed1e17..608626a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,11 +11,9 @@ 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). -Until preparing the `1.0.0rc1` release, run ctmodbus and its tests with the -adjacent ctui checkout by adding `--with-editable ../ctui` to `uv run`. This is -a temporary development override: do not change the declared ctui dependency -or lockfile merely to use the checkout. Release validation must omit the -override and use the intended published PyPI version. +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 @@ -43,11 +41,11 @@ names and configuration fields remain framework-owned. ```bash uv sync --locked --python 3.11 -uv run --with-editable ../ctui ctmodbus -uv run --python 3.11 --with-editable ../ctui python -m unittest discover -s tests -v -uv run --with-editable ../ctui black --check src tests -uv run --with-editable ../ctui isort --check-only src tests -uv run --with-editable ../ctui pylint src/ctmodbus +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 diff --git a/CHANGELOG.md b/CHANGELOG.md index ffaf721..8e6b08b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [1.0.0rc1] - Unreleased +- Require published stable `ctui==1.0.0`; end the temporary adjacent-checkout + development override. + - Accept mixed decimal and `0x`/`0b`/`0o`/`0d` literals in address-based holding-register write lists, including signed 16-bit values encoded as two's complement. Preserve unsigned 0–65535 values and single-request limits. diff --git a/README.md b/README.md index ff5550b..11ce6ab 100644 --- a/README.md +++ b/README.md @@ -21,17 +21,14 @@ uv tool install . For development: ```bash -uv sync --locked -uv pip install --python .venv/bin/python --editable ../ctui -uv run --with-editable ../ctui ctmodbus +uv sync --locked --python 3.11 +uv run ctmodbus ``` -Until release preparation, use the adjacent `../ctui` development checkout. -The editable install makes it available to IDEs using `.venv/bin/python`; -repeat that install after `uv sync`, which can restore the locked package. -Keep `--with-editable ../ctui` on development `uv run` commands. This temporary -setup leaves the declared dependency and lockfile unchanged; release validation -must use the intended published ctui version without the override. +Development and release validation use the published `ctui==1.0.0` dependency. +The temporary adjacent-checkout override has ended. Run `uv sync --locked` to +replace an older editable ctui install; IDEs using `.venv/bin/python` will then +use the locked package too. After a release candidate has been published, install it with `uv tool install 'ctmodbus==1.0.0rc1'` or @@ -398,10 +395,10 @@ successful-command behavior; protocol errors are retained as operation records. ## Development and validation ```bash -uv run --with-editable ../ctui python -m unittest discover -s tests -v -uv run --with-editable ../ctui black --check src tests -uv run --with-editable ../ctui isort --check-only src tests -uv run --with-editable ../ctui pylint src/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 build ``` @@ -409,7 +406,7 @@ Tests use isolated temporary projects, injected clients, actual local TCP/UDP servers, and a terminal runtime with injected input/output. POSIX systems also exercise RTU/ASCII over bridged pseudo-terminals; Windows skips those PTY tests. For a local manual TCP fixture, run -`uv run --with-editable ../ctui python tests/server.py tcp --port 5020`. +`uv run python tests/server.py tcp --port 5020`. The same fixture supports `udp`, `rtu`, and `ascii`; serial fixtures take `--target DEVICE`. TLS integration tests use temporary certificates generated with `openssl` and are skipped if that executable is unavailable. Physical serial adapters still diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index e45454b..82d7f00 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -104,6 +104,12 @@ 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. diff --git a/docs/STATUS.md b/docs/STATUS.md index 1de50fd..d8e4fc8 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,8 +1,8 @@ # Project status -Last reconciled: 2026-10-23, `dev-v1.0.0` at `5db3c8e`, with local radix and -signed 16-bit address-based register writes. These changes remain uncommitted; -the prior bulk tag deletion work is committed at `5db3c8e`. +Last reconciled: 2026-10-23, `dev-v1.0.0` at `9c15d08`, with an uncommitted +update to published stable `ctui==1.0.0`. Radix and signed register writes are +committed at `9c15d08`; local published-dependency checks below pass. ## Current state @@ -16,18 +16,20 @@ the prior bulk tag deletion work is committed at `5db3c8e`. - 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 remains `1.0.0rc1`; dependencies are `ctui==1.0.0rc1` and +- Version remains `1.0.0rc1`; dependencies are `ctui==1.0.0` and `pymodbus[serial]>=3.15.0,<3.16` (locked to 3.15.0), Python >=3.11,<4. -- During development, local commands and tests use the adjacent `../ctui` - checkout through `uv run --with-editable ../ctui`. This temporary override - leaves dependency metadata unchanged. Release validation must use the chosen - published PyPI ctui version without the override. -- IDEs using `.venv/bin/python` need the adjacent checkout installed directly - with `uv pip install --python .venv/bin/python --editable ../ctui` after - `uv sync`. The run-time override alone uses a separate environment. See the - [development setup](../README.md#installation). -- The owner reported RC1 unpublished on September 18. Remote refs, CI, and - publication state have not been refreshed; CHANGELOG remains Unreleased. +- 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#installation) and D06. +- October 23 release review confirmed ctui 1.0.0 on PyPI; ctmodbus remains + unpublished at 1.0 and CHANGELOG remains Unreleased. Existing + [CI at 9c15d08](https://github.com/ControlThings-io/ctmodbus/actions/runs/40051287256) + failed because the old ctui RC1 dependency lacks newer imports. The updated + dependency passes local tests/lint; remote CI has not run on this change. + 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. @@ -36,7 +38,7 @@ the prior bulk tag deletion work is committed at `5db3c8e`. - 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 adjacent development checkout. + 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. @@ -115,14 +117,13 @@ the prior bulk tag deletion work is committed at `5db3c8e`. 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. Before `1.0.0rc1` release validation, select the published PyPI ctui version, - update the dependency and lockfile if needed, and stop using the local - editable override. Then run the [release checklist](../RELEASE_CHECKLIST.md) - on the intended release revision, including the full platform matrix, - artifacts, terminal checks, and physical RTU/ASCII devices. Record actual - evidence here. -4. Obtain release approval before publication. Before stable 1.0, adopt tested - stable ctui and repeat release gates (D08). +3. Prepare ctmodbus stable version metadata, changelog and release documentation; + the ctui dependency and lockfile now use published stable 1.0.0. Run the + [release checklist](../RELEASE_CHECKLIST.md) on the intended release revision, + including the full remote platform matrix, artifacts, terminal/browser + checks, and physical RTU/ASCII devices. Record actual evidence here. +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. No implementation blocker is established; @@ -130,6 +131,20 @@ release readiness lacks final-revision and manual/remote validation. ## Validation evidence +- 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, diff --git a/pyproject.toml b/pyproject.toml index 25566f7..f5ca934 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "ctui==1.0.0rc1", + "ctui==1.0.0", "pyserial>=3.5,<4", "pymodbus[serial]>=3.15.0,<3.16", "tabulate>=0.8.9,<0.9", diff --git a/uv.lock b/uv.lock index 13f758c..dbcba1f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,11 +1,193 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.11, <4" resolution-markers = [ "python_full_version >= '3.12'", "python_full_version < '3.12'", ] +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +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/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 = "aiohttp" +version = "3.14.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { 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/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 = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +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/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 = "aiosqlite" version = "0.22.1" @@ -24,6 +206,15 @@ 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" }, ] +[[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" @@ -103,7 +294,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "ctui", specifier = "==1.0.0rc1" }, + { 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" }, @@ -119,18 +310,18 @@ dev = [ [[package]] name = "ctui" -version = "1.0.0rc1" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "aiohttp" }, { name = "aiosqlite" }, { name = "platformdirs" }, { name = "prompt-toolkit" }, { name = "pygments" }, - { name = "tabulate" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/de/7f91dcb8e99933dcfb07639ce29dcd6b4b1dacec441ee092b544ef6e437e/ctui-1.0.0rc1.tar.gz", hash = "sha256:81400752d39fefa845349b232adf928ff102a751692a8bce7b42f21c8ec401eb", size = 61260, upload-time = "2026-09-14T00:28:42.644Z" } +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/f0/15/a05b4c3590bfce406d79cc89363fc0e01290b531893e64a5d46f84d80539/ctui-1.0.0rc1-py3-none-any.whl", hash = "sha256:4a3f58f36805ba37ffa012a5385d28392c3a4a0c18515d937faa0ac33929b207", size = 63726, upload-time = "2026-09-14T00:28:41.084Z" }, + { 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]] @@ -142,6 +333,120 @@ 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 = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +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/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 = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +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/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" @@ -160,6 +465,164 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, ] +[[package]] +name = "multidict" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +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/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 = "mypy-extensions" version = "1.1.0" @@ -208,6 +671,134 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, ] +[[package]] +name = "propcache" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +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/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 = "psutil" version = "7.2.2" @@ -338,6 +929,15 @@ 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.16.0" +source = { registry = "https://pypi.org/simple" } +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" }, +] + [[package]] name = "wcwidth" version = "0.8.2" @@ -346,3 +946,136 @@ sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288be wheels = [ { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, ] + +[[package]] +name = "yarl" +version = "1.25.1" +source = { registry = "https://pypi.org/simple" } +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/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" }, +] From 96c3844138e2af0c5e37f6360433112ff4b8aa2d Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Sat, 24 Oct 2026 00:28:43 -0600 Subject: [PATCH 32/38] docs: refresh README and validate examples --- README.md | 728 +++++++++++++++++++++++++++---------------------- docs/SERVER.md | 7 +- docs/STATUS.md | 41 ++- 3 files changed, 444 insertions(+), 332 deletions(-) diff --git a/README.md b/README.md index 11ce6ab..1c11af9 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,66 @@ # ControlThings Modbus -An asynchronous Modbus tool for device testing, built with -[ctui](https://github.com/ControlThings-io/ctui) and +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). -The 1.0 release candidate supports TCP, UDP, TLS, serial RTU, and serial ASCII; -device identification; coils, discrete inputs, input registers, and holding -registers; and single/multiple coil and holding-register writes. -Project-scoped tags add names and integer, floating-point, or Boolean types to -those wire addresses. +Features in 1.0.0: + +- 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. Install the release candidate from this checkout: +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 -uv tool install . +pip3 install ctmodbus ``` -For development: +Or better yet, use `uv` to install it in an isolated environment: ```bash -uv sync --locked --python 3.11 -uv run ctmodbus +uv tool install ctmodbus ``` -Development and release validation use the published `ctui==1.0.0` dependency. -The temporary adjacent-checkout override has ended. Run `uv sync --locked` to -replace an older editable ctui install; IDEs using `.venv/bin/python` will then -use the locked package too. +Just trying it out? Run it with `uvx`: + +```bash +uvx ctmodbus +``` -After a release candidate has been published, install it with -`uv tool install 'ctmodbus==1.0.0rc1'` or -`python -m pip install 'ctmodbus==1.0.0rc1'`. +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 -Run `ctmodbus` without arguments to open the terminal interface. Commands have -automatic completion, generated help, and unique-prefix matching. Use `help` -for the full list, including ctui's project, config, and history 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. -Larger commands follow **component → action → transport or target**: -`client start tcp HOST`, `server start tcp HOST`, and `proxy start`. -Bare `client`, `server`, and `proxy` open example help in TUI/WUI popups -and print examples in CLI. `help client`, `help server`, and `help proxy` remain generated references. Old connect/serve/profile/close/cancel -spellings and proxy enable/disable are removed; ordinary unique prefixes remain. +Connect to a device, then read or write its addresses: ```text -client discover # suggest serial ports and local services client start tcp 10.10.10.1 --port 502 --unit 1 -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 read id read discrete_inputs 1 read coils 1,3,5,7 @@ -64,44 +72,92 @@ 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 ``` -These examples show alternative connections: close the current session before -opening another. Serial-device completion shows the USB manufacturer and product -beside each device path when the operating system provides them. Discovery is -advisory; device paths and symlinks can be supplied even if enumeration does not -list them. UDP opening establishes a local socket and does not prove that a -remote Modbus device is present. - -Addresses are **zero-based wire addresses**, from 0 through 65535, rather than -4xxxx reference numbers. Ranges are inclusive and preserve input order and -repetitions. A read accepts at most 65,536 addresses including repetitions. -`--max-count` limits each request, not the total number of addresses: up to -2,000 for bits and 125 for registers. Use `50-58` to read nine addresses at 50. - -Writes accept comma-separated integers: 0/1 for coils and -32768–65535 for -holding registers. Each register value occupies one 16-bit register; negative -values use two's complement (`-1` writes `0xFFFF`). Register values accept -decimal and `0x`/`0b`/`0o`/`0d` prefixes, optional signs, and underscores, -using the same integer literal syntax as tags. Prefixes may be mixed in one -list: `0x8000,0b0100110,0d800` writes 32768, 38, and 800 to consecutive -addresses. Use `--` before a list starting with a negative value. Omit spaces -around commas or quote the entire list. Results and records show unsigned -wire values. Use typed tags for floating-point or multi-register values. - -One value selects a single write; multiple values select one multiple-write -request (at most 1,968 coils or 123 registers). Writes never split automatically. -Success means the device returned a matching acknowledgement, not an independent -readback. Failed or interrupted writes may have reached the device; an -unconfirmed outcome is reported explicitly. +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 -Tags give device addresses project-scoped names and types. The type determines -the number of addresses, so creation takes only the first zero-based address. -Tags use the active connection and unit; they do not store a host, unit, or -connection profile. +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 @@ -118,73 +174,73 @@ write tag timer 0x8000_0000 write tag timer -- -2_000_000_000 write tag state 0b0111_0001 write tag pause_sw on -tags rename timer duration -tags delete duration -tags export my_tags -tags import my_tags.toml ``` -Types are `bool`, signed and unsigned 8-, 16-, 32-, and 64-bit integers, and -32- or 64-bit floating point. Integer input accepts signed decimal values and -`0b`, `0o`, `0d`, or `0x` radix prefixes; underscores are allowed. Boolean input -accepts `0`/`1`, `false`/`true`, and `off`/`on`. Floating input accepts finite -decimal or scientific notation; NaN and infinity are rejected. +`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. -For signed integer tags, unsigned binary, octal, or hexadecimal literals are -interpreted as fixed-width bit patterns: `0x8000_0000` is `-2147483648` for an -`int32`, and `0xFFFF_FFFF` is `-1`. Decimal input remains numeric and must fit -the declared range. Place ctui's `--` end-of-options marker before a negative +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`. -Register tags default to Modbus-standard big-endian byte order inside each -16-bit register and little-endian word order across registers. Override these -with `--byte-order little|big` and `--word-order little|big`. An 8-bit value -occupies the selected byte and the unused byte is written as zero: big byte -order places it in the low byte; little byte order places it in the high byte. -Byte and word order do not apply to Boolean tags. - -`tags create` uses the singular table names `coil`, `discrete_input`, -`input_register`, and `holding_register`. Coils and discrete inputs support -`bool`; input and holding registers support numeric types. Writes are limited to -coils and holding registers. Overlapping tags are allowed and reported when -created because alternate interpretations can be useful. Tagged operations -retain normal response validation, recording, cancellation, and uncertain-write -reporting. Multi-tag reads hold one connection reservation across the command, -with one request per tag. Failure or cancellation preserves completed decoded -rows, identifies the failed tag, and reports how many remaining tags were not -read. Cancellation closes the connection; reconnect before further device I/O. -This prevents command interleaving, not changes to device values between reads. - -`read tags` without names reads every tag in name order. Supply a comma-separated -list to read only those tags while preserving the requested order and duplicates. - -Tag files are versioned TOML. Export appends `.toml` when absent and replaces -the destination atomically. Import validates the whole file before changing the -project. Imports retain the exact validated content during confirmation and -reserve the project and tag edits until finished. The tag merge commits all -rows or none. Both commands suggest filesystem paths in the TUI. Existing names -trigger a TUI confirmation; CLI and command-file use report the conflicting -names and require `--replace`. - -Concurrent exports still assume sequential use. See the -[documented concurrency limitations](docs/DECISIONS.md#d12--implementation-discrepancies-and-ctui-proposals) -before embedding concurrent tag-management or project-switching commands. +### 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 all project tags with -`tags delete --all`. The server and polling must be stopped. Bulk deletion -requires a Yes/No confirmation in TUI/WUI, or explicit `--confirm` in CLI; -an empty project reports "No tags to delete" without prompting. A name and -`--all` cannot be combined. +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 tag entries prevent deletion by default. The error lists referenced -tags; add `--remove-server-rules` to remove those entries in the same atomic -transaction. This also works for one tag and requires approval. Raw ranges, -table defaults, identity, unit/seed settings and hooks stay unchanged. If hooks -exist, the confirmation warns that Python references may need manual updates; -companion scripts are never inspected or rewritten. Hook warnings also require -approval for a single unreferenced tag. +Saved server rules referencing a tag block deletion. Add +`--remove-server-rules` to delete those entries in the same transaction: ```text tags delete temperature @@ -193,18 +249,18 @@ tags delete --all tags delete --all --remove-server-rules --confirm ``` -Declining or cancelling approval changes nothing. Failures within the deletion transaction roll back -both tag removal and server configuration changes. Completion uses the existing -result MessageDialog in TUI/WUI. Exported TOML files remain unchanged; importing -one later can recreate its tags. +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 -Start one poll in the TUI or WUI; commands remain available while it runs. -`poll tags` snapshots all tags in name order, or takes an ordered comma-separated -list. `poll raw` takes one or more table options using the same inclusive address -syntax as `read`. Each address/range is a separate column; table options and -ranges retain their command-line order, including duplicates. +Run one poll at a time while keeping the terminal/web UI available: ```text poll tags @@ -214,22 +270,24 @@ poll status poll stop ``` -The interval defaults to one second. Interval and duration are positive finite -seconds; count is a positive number of started cycles. Both limits work in the -TUI and WUI; when both are supplied, the first reached stops new cycles. Duration -limits cycle starts, allowing an active cycle to finish. In CLI `-c`/`-f` mode, -polling streams output and waits for completion before the next command; supply -limits for scripts. An unlimited CLI poll runs until interrupted. +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 first cycle starts immediately. Later cycles target a fixed monotonic-clock -schedule (`start + n × interval`), without adding response time to the interval. -Each cycle serializes all selected reads on the existing connection. Ticks while -that cycle or another device operation is busy are skipped, never queued or -replayed. Event-loop delays also skip obsolete ticks. Scheduling is best effort, -not a guarantee of exact wire timing. `poll status` reports started/completed -cycles, skipped ticks, failed columns, limits, targets, and last cycle duration. +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. -A header and one line per started cycle append to existing output: +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 @@ -241,74 +299,144 @@ A header and one line per started cycle append to existing output: 2 | 0000 0072 ERR | 000B 0100 | 10110011 ``` -Column widths are set when polling starts from headers, tag type bounds, and raw -range lengths. The `#` counter and numeric tags align right; booleans and raw -sequences align left, with ` | ` between every column. Unlimited polls reserve -six counter characters; bounded polls reserve at least three. If any value or -counter outgrows its width, a wider header precedes that row; earlier rows retain -their original spacing. Floats use up to nine significant digits for float32 and -17 for float64, with scientific notation where appropriate and `.0` for integral -values displayed without an exponent. Raw operation records remain unchanged. - -Tags display decoded integers, floats, or booleans. Raw bits use contiguous -`0`/`1`; registers use four uppercase hex digits without `0x`, separated by -spaces. A failed tag displays `ERR`; a raw range retains confirmed chunks and -appends `ERR` if a later chunk fails. Other columns and cycles continue while -the connection remains usable. Existing operation records retain confirmed raw -values and detailed errors; previous values are never presented as fresh results. -All completed rows append, including unchanged values. - -Tag definitions cannot be edited during polling or target preparation. Stop the -poll before changing projects; polls never follow a replacement connection. -`poll stop` stops future ticks and waits for the active cycle without closing the -connection. `client stop`, connection loss, or application shutdown stop -polling; cancellation closes the transport and requires explicit reconnection. +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. -## Management result popups +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. -In TUI/WUI, ctmodbus management results use standard scrollable message dialogs -with descriptive titles. This includes start/stop, list/show/status, import/export, -discovery, validation, configuration set/save/load, tag create/rename/delete, -server rule/hook edits, logging switches, reset, and clear. Actions finish before -the dialog opens. Dismiss it before submitting another command; running servers, -proxying, polling, and request logging continue. Main output remains intact. -Existing confirmation dialogs still precede actions requiring approval. +## Server -Read/write results, polling rows, and server/proxy request logs stay in the main -output pane. Bare component help keeps its standard help popup. Inherited ctui -commands retain their existing behavior. CLI management results remain plain text. +Run one server alongside the independent client. In a fresh project, the server +exposes all four tables at addresses 0–65535, initially false/zero: -## Connection options and operation results +```text +server start tcp 127.0.0.1 --port 5020 +server status +server stop +``` + +Server TCP/UDP default to loopback port 5020; TLS defaults to loopback port +8020 and requires certificate/key files. RTU/ASCII servers use their own serial +port and respond as devices, not passive taps. + +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. + +See the [server guide](docs/SERVER.md) for configuration, hooks, and reset +behavior. + +### 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 logs append +to output in terminal, web, and CLI modes. -All connections accept `--unit` (1–247), `--timeout` (seconds), and `--retries` -(0–10). Defaults are unit 1, zero retries, and a timeout of 3 seconds for network -connections or 1 second for serial connections. Serial defaults are 9600 baud, -8 data bits, no parity, and 1 stop bit; override these with `--baudrate`, -`--bytesize`, `--parity`, and `--stopbits`. TCP/UDP ports default to 502 and TLS to 802; supply -host and port separately, including for IPv6 addresses. - -`client start tls HOST` uses Modbus TLS framing and verifies the server certificate -against system trust roots and the supplied hostname. Use `--ca-file PATH` for -a private CA and `--cert-file PATH --key-file PATH` for client authentication. -A certificate file may contain its private key; otherwise supply `--key-file`. -Keys must be unencrypted. Certificate loading runs off the event loop. -`--insecure` explicitly disables server certificate and hostname verification. -Profiles save these file paths and the verification setting, not certificate or -private-key contents; the files must remain available when reconnecting. - -Device I/O is asynchronous, with requests serialized on the single connection. -The status bar shows the project, transport state, and read progress. `client stop` cancels outstanding device work and closes the transport. Reconnect -before issuing more requests. The tool does not automatically reconnect. -Ctrl-C clears the input line; use `client stop` to stop running device commands. - -Read output includes UTC timestamps and compressed address/value summaries. -Register output includes integer, hex, and character views; the character view -is a Unicode code point per register, not a UTF-8 string decoder. Failed chunked -reads include the completed values in the error output. +## 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 zero +client retries and maps the server unit to the connected client's unit. +`proxy stop` restores local simulation. Upstream errors pass through; connection +failure never silently falls back to simulated values. Stopping either endpoint +while proxying needs confirmation; CLI can supply `--confirm`. + +`client status` and `server status` show last observations without issuing new +requests. Read values, write acknowledgements, and uncertain writes stay +separate. These views are runtime-only; saved operation records remain available. +See the [proxy guide](docs/SERVER.md#proxying-and-evidence-views) for full +routing details. + +### 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 -The same commands work without opening the terminal UI: +Use the same commands without opening a UI: ```bash ctmodbus --help @@ -317,25 +445,24 @@ ctmodbus -c 'client start tcp 127.0.0.1 --port 5020' \ ctmodbus -f commands.txt ``` -Repeat `-c`/`--command` and `-f`/`--file` in the desired order. Command files -contain one command per line; blank lines and lines starting with `#` are -ignored. Quote complete commands at the shell. Inline comments in the -interactive examples above are explanatory and should be omitted when typing. +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. -Commands execute sequentially in CLI mode and stop at the first error. Exit -status is 0 for success, 2 for command/argument/protocol errors, and 1 for -unexpected command failures. After a successful CLI command sequence, the process stays alive if a server -is still running. Ctrl-C stops it and closes both server and client. Include -`server stop` to finish a temporary-server sequence and exit normally. An error -or explicit exit command skips keepalive and shuts everything down. Server starts -return immediately to allow subsequent commands; `--foreground` is removed. +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 -ctui stores projects in its platform-specific user data directory under the -application ID `io.controlthings.ctmodbus`. Each project contains named configs, -command history, connection recording sessions, and decoded Modbus operation -records. Live connections are never persisted. +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 @@ -348,19 +475,21 @@ client config save lab client start read holding_registers 0-9 client stop +project history export commands.txt project export lab.ctui-project -history export commands.txt project create another-lab project load default client config load lab ``` -`tcp-local` and `udp-local` are built-in localhost profiles. `client config save NAME` -saves selected settings, even while disconnected; `client config load NAME` -validates and selects them without connecting. Use `client start` to connect. -`client config show` displays all selected fields, the source name, and unsaved -changes. `client config set` changes only supplied options, atomically, while -the client is stopped. The first selection requires `--transport` and `--target`: +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 @@ -370,120 +499,71 @@ client start client status client stop client config load lab -client start ``` -Settings include transport, target, port, unit, timeout, retries, serial -baudrate/data bits/parity/stop bits, TLS certificate paths, and insecure mode. -Clear TLS paths with `--clear-ca-file`, `--clear-cert-file`, and -`--clear-key-file`; use `--insecure false` to restore verification or -`--insecure true` to disable it. Edits retain omitted values, including when -changing transport, so clear incompatible TLS fields in the same edit. -File contents, live sockets, polling tasks, and runtime evidence are never -saved in client configs. Selection survives client stop and clears on project -transitions. -ctui also supplies config JSON import/export and whole-project import/export. -Close the connection and finish device work before switching or resetting -projects; this keeps protocol records in the project that initiated them. - -Records contain attempted requests, decoded responses, and operation errors, -linked to a session containing connection settings. They are not raw packet -captures. Recording errors are reported separately so an acknowledged write is -not mistaken for a failed write and retried. Command history follows ctui's -successful-command behavior; protocol errors are retained as operation records. +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 isolated temporary projects, injected clients, actual local TCP/UDP -servers, and a terminal runtime with injected input/output. POSIX systems also -exercise RTU/ASCII over bridged pseudo-terminals; Windows skips those PTY tests. -For a local manual TCP fixture, run -`uv run python tests/server.py tcp --port 5020`. -The same fixture supports `udp`, `rtu`, and `ascii`; serial fixtures take -`--target DEVICE`. TLS integration tests use temporary certificates generated with `openssl` and -are skipped if that executable is unavailable. Physical serial adapters still -need hardware smoke testing. - -This is a breaking migration from 0.x. Legacy command spellings, space-separated -multi-write values, host:port syntax, and old storage formats are not supported. -Polling, device cloning, MITM rules, raw/fuzzy requests, -tunneling, and historian integration remain deferred. - -See [MIGRATION.md](MIGRATION.md) for the 0.x transition and -[CHANGELOG.md](CHANGELOG.md) for release-facing changes. Current work and -validation gaps are in [docs/STATUS.md](docs/STATUS.md), architecture and rationale -in [docs/DECISIONS.md](docs/DECISIONS.md), and publication procedures in -[RELEASE_CHECKLIST.md](RELEASE_CHECKLIST.md). Local implementation contracts live -in module, class, and callable docstrings. +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. -## License +For a manual TCP fixture, run: -Copyright Justin Searle. Licensed under the GNU General Public License, -version 3 or later. See [LICENSE](LICENSE). -## Serving and proxying - -ctmodbus can serve TCP, UDP, TLS, RTU and ASCII while maintaining an independent -client connection. Configure sparse raw ranges or typed tags with static, -random and sequence behavior, plus optional Python hooks. Server definitions -can be assembled in CTUI or imported/exported as TOML. - -```text -server config import examples/device.toml -server start tcp 127.0.0.1 --port 5020 -server status -server stop -client start tcp 127.0.0.1 --port 502 -client status +```bash +uv run python tests/server.py tcp --port 5020 ``` -`proxy start` routes incoming server requests through the connected client; -`proxy stop` restores local behavior. See [server and proxy usage](docs/SERVER.md) -for all commands, CLI server keepalive, hooks, defaults and evidence semantics. +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. -### Server and proxy request output +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. -Server listeners append one line per decoded incoming request by default. -Enabling the proxy adds a forwarding line with the same application-local request -ID. Lines include UTC millisecond timestamps, source, peer or upstream endpoint, -unit, function/table, and address range. Writes show compact bits or four-digit -hex registers, capped at 16 displayed values with an omitted-value count; server -operation records retain the full write payload. Arrival/forwarding lines do not -imply a successful response or write acknowledgement. - -```text -server start tcp 127.0.0.1 --port 5020 --quiet -proxy start --quiet -server logging on -proxy logging on -server logging off -proxy logging off -server status -proxy status -``` - -`--quiet` is available on every `server start` transport and on `proxy start`. Server -arrival and proxy forwarding logging are independent. Live `logging on/off` -commands change routine verbosity without stopping the listener or disabling -records. Starting a listener or enabling the proxy resets its logging policy -from that command's `--quiet` option; settings are runtime-only. - -```text -2026-10-06T14:32:05.123+00:00 SERVER #42 peer=('127.0.0.1', 53120) unit=1 read holding_registers 10-12 -2026-10-06T14:32:05.124+00:00 PROXY #42 upstream=TCP 192.168.1.20:502 unit=7 read holding_registers 10-12 -``` +## License -Request failures remain visible in quiet mode. Failed proxy writes distinguish -potentially unconfirmed outcomes; upstream exceptions are shown without implying -forwarding success. Display escapes control characters. Polling and server logs -share one synchronous append path for terminal, browser, and CLI output. CLI -listeners remain running automatically after a successful command sequence. Malformed/unsupported -requests that the decoder reports also produce diagnostic lines. Future MITM -logging is not implemented. +Copyright Justin Searle. Licensed under the GNU General Public License, +version 3 or later. See [LICENSE](LICENSE). diff --git a/docs/SERVER.md b/docs/SERVER.md index a42b67c..153bdff 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -42,7 +42,7 @@ returns immediately. After all commands succeed, CLI stays alive if a listener remains running: ```bash -uv run --with-editable ../ctui ctmodbus \ +uv run ctmodbus \ -c "server config import examples/device.toml" \ -c "server start tcp 127.0.0.1 --port 5020" ``` @@ -316,5 +316,6 @@ suppresses routine forwarding lines. `server logging on/off` and `proxy logging on/off` update these policies live, and status commands show them. Errors bypass both quiet settings; operation recording is independent of display. Request IDs increase across listener restarts within an application, and verbosity -settings are runtime-only. See the [usage examples](../README.md#server-and-proxy-request-output) -for timestamps, compact previews, and CLI behavior. +settings are runtime-only. See the [server logging examples](../README.md#server-request-output) +and [proxy logging examples](../README.md#proxy-request-output) for timestamps, +compact previews, and CLI behavior. diff --git a/docs/STATUS.md b/docs/STATUS.md index d8e4fc8..ca42131 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,11 +1,20 @@ # Project status -Last reconciled: 2026-10-23, `dev-v1.0.0` at `9c15d08`, with an uncommitted -update to published stable `ctui==1.0.0`. Radix and signed register writes are -committed at `9c15d08`; local published-dependency checks below pass. +Last reconciled: 2026-10-24, `dev-v1.0.0` at `1fda698`, with an uncommitted +README rewrite and example audit. Published stable `ctui==1.0.0` is committed +at `1fda698`; local documentation and regression checks below pass. ## Current state +- 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. Corrected project history + export, stale polling deferral, and the linked server guide's ctui override. + 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, @@ -22,12 +31,13 @@ committed at `9c15d08`; local published-dependency checks below pass. 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#installation) and D06. + [development setup](../README.md#development-and-validation) and D06. - October 23 release review confirmed ctui 1.0.0 on PyPI; ctmodbus remains unpublished at 1.0 and CHANGELOG remains Unreleased. Existing [CI at 9c15d08](https://github.com/ControlThings-io/ctmodbus/actions/runs/40051287256) failed because the old ctui RC1 dependency lacks newer imports. The updated - dependency passes local tests/lint; remote CI has not run on this change. + dependency passes local tests/lint; remote CI after the dependency update + has not been reviewed here. The GitHub `pypi` environment exists; PyPI trusted publisher configuration still needs verification. - The September 21 audit updates global documentation ownership rules, expands @@ -122,6 +132,7 @@ committed at `9c15d08`; local published-dependency checks below pass. [release checklist](../RELEASE_CHECKLIST.md) on the intended release revision, including the full remote platform matrix, artifacts, terminal/browser checks, and physical RTU/ASCII devices. 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). @@ -131,6 +142,26 @@ release readiness lacks final-revision and manual/remote validation. ## Validation evidence +- 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, From 68d7aa38a223a2ff3accc8839afb2238afcd2536 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Sat, 24 Oct 2026 00:35:25 -0600 Subject: [PATCH 33/38] docs: consolidate server and proxy guidance in README --- AGENTS.md | 2 +- README.md | 290 ++++++++++++++++++++++++++++++++++++++--- docs/DECISIONS.md | 3 +- docs/SERVER.md | 321 ---------------------------------------------- docs/STATUS.md | 28 +++- 5 files changed, 296 insertions(+), 348 deletions(-) delete mode 100644 docs/SERVER.md diff --git a/AGENTS.md b/AGENTS.md index 608626a..a35f3cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ Publishing requires release approval; pushing a version tag triggers publication - `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. -- [docs/SERVER.md](docs/SERVER.md): server, proxy, configuration and hook contracts. +- [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 diff --git a/README.md b/README.md index 1c11af9..27b59bc 100644 --- a/README.md +++ b/README.md @@ -322,9 +322,27 @@ server status server stop ``` -Server TCP/UDP default to loopback port 5020; TLS defaults to loopback port -8020 and requires certificate/key files. RTU/ASCII servers use their own serial -port and respond as devices, not passive taps. +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 @@ -346,8 +364,213 @@ replaces the server definition and merges its tags; conflicting tags need approval or `--replace`. Python hooks execute as trusted application code, including during validation. -See the [server guide](docs/SERVER.md) for configuration, hooks, and reset -behavior. +### 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 @@ -370,8 +593,9 @@ server logging on ``` Errors remain visible and recording continues. Each server start resets its -logging setting from `--quiet`; the setting isn't saved. Request logs append -to output in terminal, web, and CLI modes. +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 @@ -390,17 +614,47 @@ server stop client stop ``` -Any supported client/server transport combination works. Proxying requires zero -client retries and maps the server unit to the connected client's unit. -`proxy stop` restores local simulation. Upstream errors pass through; connection -failure never silently falls back to simulated values. Stopping either endpoint -while proxying needs confirmation; CLI can supply `--confirm`. - -`client status` and `server status` show last observations without issuing new -requests. Read values, write acknowledgements, and uncertain writes stay -separate. These views are runtime-only; saved operation records remain available. -See the [proxy guide](docs/SERVER.md#proxying-and-evidence-views) for full -routing details. +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 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 82d7f00..1fd844c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -292,7 +292,8 @@ 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. -The detailed command/config/hook contract is in [SERVER.md](SERVER.md). +Server configuration, hooks and proxy usage are documented in +[README.md](../README.md#server). ## D14 — Fixed-cadence polling and appended evidence rows diff --git a/docs/SERVER.md b/docs/SERVER.md deleted file mode 100644 index 153bdff..0000000 --- a/docs/SERVER.md +++ /dev/null @@ -1,321 +0,0 @@ -# Serving and proxying Modbus - -`server start` creates one application-owned listener, independent of the single client -opened by `client start`. Configure the server before starting it. The TUI remains -interactive; stopping the server keeps the client connection open. Exiting shuts -both down. Project changes and tag/definition edits require a stopped server. -Definitions are saved per project through ctui configs; live observations, tasks, -values and Python functions are not persisted. Whole-project snapshots include -the definition and tags, but not external Python or certificate files. - -## Start and stop - -```text -server config import examples/device.toml -server config validate -server start tcp 127.0.0.1 --port 5020 -server config show -server status -server stop -``` - -Other listeners share the same local definition: - -```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. -Explicit ports 502/802 work when the operating system permits binding them. -Serial defaults match `client start`: 9600 baud, 8 data bits, no parity, one stop bit. -Serial serving responds as a device, not a passive tap. The client and listener -must use different serial ports. Unit IDs 1–247 are supported, defaulting to 1; -serial traffic for other units (including broadcasts) is ignored. -TLS uses native PyModbus TLS framing. Certificate and unencrypted key files are -required. `--ca-file` additionally requires trusted client certificates. - -The shared dispatch path supports CLI command sequences. Every server start -returns immediately. After all commands succeed, CLI stays alive if a listener -remains running: - -```bash -uv run ctmodbus \ - -c "server config import examples/device.toml" \ - -c "server start tcp 127.0.0.1 --port 5020" -``` - -Ctrl-C stops the listener and client cleanly. Commands following a server start -still run before keepalive. A sequence ending with server stop exits normally; -command errors or explicit exit shut down immediately. The previous -`--foreground` option is removed from every transport and interface. -TUI/WUI starts display their completed startup result in a standard message -popup while the listener and request logs remain active. Management popups -preserve the main output; see [result presentation](../README.md#management-result-popups). - -## Address maps and values - -Without a definition, all four tables implement protocol addresses 0–65535, -initialized to false or zero. Defaults are sparse: no giant initial value array. -This is permitted by Modbus Application Protocol V1.1b3 sections 4.3/4.4. A -request still obeys per-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 top-level/nested fields, and -obsolete `tick_seconds` are rejected with actionable errors. Use -`tick_interval_seconds` for periodic hooks. These unreleased formats have no -legacy migration; update old files explicitly. ctui-owned config fields are -unchanged. - -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. - -## Assemble a definition in CTUI - -```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. - -Tag deletion refuses names referenced by saved server tag entries. Use -`tags delete NAME --remove-server-rules` or -`tags delete --all --remove-server-rules` to remove both atomically, after -confirmation (CLI requires `--confirm`). The server and polling must be stopped. -Raw table rules, fallback settings and hooks are preserved; the confirmation -warns about retained hooks that might reference deleted names. See -[tag removal](../README.md#removing-tags). - -## Optional Python hooks - -The [pump example](../examples/device.toml) references a companion -[Python module](../examples/device_logic.py): - -```toml -[hooks] -module = "device_logic.py" -on_read = "on_read" -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. - -Supported signatures: - -```python -def on_read(device, tag_names): - # Once per local read, including partial tags. Return value is ignored. - device.set("temperature_f", device.get("temperature_c") * 1.8 + 32) - -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. - -## Proxying and evidence views - -```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 -``` - -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. - -Proxying requires both endpoints. 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. - -`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. Proxy upstream values are -recorded before the response extension point; downstream values afterwards. -The two views are independent. 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. Future MITM rules will use a separate TOML file; -only request/response extension seams exist today. - -## Request display controls - -Listeners default to appending request-arrival lines; proxy mode additionally -appends forwarding lines with the same request ID. `--quiet` on any `server start` -transport suppresses routine server lines. `proxy start --quiet` independently -suppresses routine forwarding lines. `server logging on/off` and -`proxy logging on/off` update these policies live, and status commands show them. -Errors bypass both quiet settings; operation recording is independent of display. -Request IDs increase across listener restarts within an application, and verbosity -settings are runtime-only. See the [server logging examples](../README.md#server-request-output) -and [proxy logging examples](../README.md#proxy-request-output) for timestamps, -compact previews, and CLI behavior. diff --git a/docs/STATUS.md b/docs/STATUS.md index ca42131..37a981f 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,8 +1,8 @@ # Project status -Last reconciled: 2026-10-24, `dev-v1.0.0` at `1fda698`, with an uncommitted -README rewrite and example audit. Published stable `ctui==1.0.0` is committed -at `1fda698`; local documentation and regression checks below pass. +Last reconciled: 2026-10-24, `dev-v1.0.0` at `96c3844`, with an uncommitted +merge of the server guide into README. The prior README/example audit is +committed at `96c3844`; validation for this consolidation is recorded below. ## Current state @@ -10,8 +10,10 @@ at `1fda698`; local documentation and regression checks below pass. 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. Corrected project history - export, stale polling deferral, and the linked server guide's ctui override. + 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. @@ -56,7 +58,7 @@ at `1fda698`; local documentation and regression checks below pass. - 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 [SERVER.md](SERVER.md), [D13](DECISIONS.md#d13--independent-servers-sparse-emulation-proxy-routing-and-evidence-views), + 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, @@ -72,7 +74,7 @@ at `1fda698`; local documentation and regression checks below pass. 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](SERVER.md#request-display-controls) and + [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 @@ -142,6 +144,18 @@ release readiness lacks final-revision and manual/remote validation. ## Validation evidence +- 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 From 08de58b3b1c264d5018e19b0ce5ca1dfabd9c932 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Sat, 24 Oct 2026 00:39:29 -0600 Subject: [PATCH 34/38] docs: prepare 1.0.0 changelog --- CHANGELOG.md | 130 ++++++++++++++++++++++------------------------ docs/DECISIONS.md | 5 ++ docs/STATUS.md | 25 ++++++--- 3 files changed, 87 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e6b08b..6e40e1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,78 +1,74 @@ # Changelog -## [1.0.0rc1] - Unreleased +## [1.0.0] - 2026-10-24 -- Require published stable `ctui==1.0.0`; end the temporary adjacent-checkout - development override. +### Added -- Accept mixed decimal and `0x`/`0b`/`0o`/`0d` literals in address-based - holding-register write lists, including signed 16-bit values encoded as - two's complement. Preserve unsigned 0–65535 values and single-request limits. +- 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. -- Add confirmed `tags delete --all` and explicit `--remove-server-rules` for - single/bulk deletion. Remove tags and referenced server tag entries atomically, - preserve other configuration, warn about hook references, and retain standard - Yes/No approval and MessageDialog results. +### Changed -- Present ctmodbus management results in standard scrollable TUI/WUI popups, - preserving main output while device work and request logging continue. -- Remove server --foreground options. CLI now runs the full command sequence, - then keeps remaining listeners alive; errors, explicit exit, and interruption - clean up server and client resources. +- 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. -- Refactor unreleased commands into client/server/proxy component groups, with - start/stop/status, separate configuration and runtime views, and example - help popups. Remove old command spellings; ctui-owned commands are unchanged. -- Add selected client configuration show/set/save/load, explicit start after - load, atomic editing, and proxy-aware component stop confirmations. -- Require strict TOML format/version markers and actionable unknown-key errors; - add the server format marker and rename hook tick_interval_seconds without - changing server version 1. +### Fixed -- Display correlated server-arrival and proxy-forwarding request lines by default, - with per-transport/proxy `--quiet`, independent live logging switches, visible - failures, bounded write previews, and full write payloads in server records. +- 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. -- Add fixed-cadence `poll tags` and multi-table `poll raw` with configurable - intervals, count/duration limits, status and graceful stop. Append compact - aligned typed/raw rows with a `#` counter in terminal and browser sessions, - retain partial read evidence, - and skip busy ticks without overlapping or accumulating device requests. +### Compatibility -- Add independent TCP/UDP/TLS/RTU/ASCII serving, project-scoped TOML definitions, - sparse table defaults, typed static/random/sequence rules, transactional Python - hooks, validation, export and runtime reset commands. -- Add serialized cross-transport proxying and separate client/downstream - observation views, retaining write uncertainty and decoded operation records. +- 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. -- Import the exact confirmed tag data, guard project/tag changes during import, - and apply tag merges in an isolated all-or-none transaction. - -- Reserve the connection across multi-tag reads and preserve completed decoded - results on failure or cancellation, identifying failed and unread tags. - -- Show USB manufacturer and product metadata in serial-device completions. -- Add project-scoped typed tags with Boolean, signed/unsigned integer, and - floating-point decoding; configurable byte/word order; tagged reads/writes; - and atomic TOML import/export with path completion through `tags export` and - `tags import`. -- Add `client start tls` with verified server certificates, custom CA roots, optional - client identity, explicit insecure mode, and persisted TLS connection settings. - -- Migrate from ctui 0.x to ctui 1.0.0rc1 and require Python 3.11+. -- Use native async PyModbus 3.15 clients for TCP, UDP, RTU, and ASCII. -- Replace global runtime state with an application-owned connection lifecycle. -- Adopt typed commands, inclusive integer ranges, named options, automatic CLI, - completion, project history, connection profiles, and decoded protocol records. -- Serialize device operations, report read progress, and add explicit cancellation. -- Check response lengths and write acknowledgements; report partial reads and - uncertain writes; repair multi-value writes and sparse device identification. -- Replace obsolete manual server scripts with one current fixture and automated - command, transport, persistence, cancellation, and terminal tests. -- Consolidate packaging and align license metadata with the GPL-3.0-or-later source. - -Breaking changes: no pre-1.0 API, command, or storage compatibility. Raw reads -and writes use plural snake_case names, comma-separated values, and separate host -and `--port` arguments. Read counts are expressed as inclusive address ranges; -`--max-count` controls request chunk size. Remove the development `debug eval` -command. Python 3.8–3.10 are no longer supported. +See [README.md](README.md) for current usage and [MIGRATION.md](MIGRATION.md) +for the transition from 0.x. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 1fd844c..42595dc 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -149,6 +149,11 @@ 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. + ## D09 — Migration commit history Accepted, 2026-09-13. diff --git a/docs/STATUS.md b/docs/STATUS.md index 37a981f..3042979 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,11 +1,17 @@ # Project status -Last reconciled: 2026-10-24, `dev-v1.0.0` at `96c3844`, with an uncommitted -merge of the server guide into README. The prior README/example audit is -committed at `96c3844`; validation for this consolidation is recorded below. +Last reconciled: 2026-10-24, `dev-v1.0.0` at `68d7aa3`, with an uncommitted +1.0.0 changelog update dated October 24. The README and server-guide +consolidation are committed; validation for the changelog is recorded below. ## Current state +- 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 and the release checklist still target RC1; + updating them and running final release gates remain next steps. The dated + changelog does not establish 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; @@ -35,7 +41,7 @@ committed at `96c3844`; validation for this consolidation is recorded below. resolve to ctui 1.0.0 in site-packages. See the [development setup](../README.md#development-and-validation) and D06. - October 23 release review confirmed ctui 1.0.0 on PyPI; ctmodbus remains - unpublished at 1.0 and CHANGELOG remains Unreleased. Existing + unpublished at 1.0 at that review. Existing [CI at 9c15d08](https://github.com/ControlThings-io/ctmodbus/actions/runs/40051287256) failed because the old ctui RC1 dependency lacks newer imports. The updated dependency passes local tests/lint; remote CI after the dependency update @@ -129,8 +135,9 @@ committed at `96c3844`; validation for this consolidation is recorded below. 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. Prepare ctmodbus stable version metadata, changelog and release documentation; - the ctui dependency and lockfile now use published stable 1.0.0. Run the +3. Update ctmodbus stable version metadata, lockfile and release checklist; + the 1.0.0 changelog is dated October 24 and the ctui dependency already uses + published stable 1.0.0. Run the [release checklist](../RELEASE_CHECKLIST.md) on the intended release revision, including the full remote platform matrix, artifacts, terminal/browser checks, and physical RTU/ASCII devices. Record actual evidence here. @@ -144,6 +151,12 @@ release readiness lacks final-revision and manual/remote validation. ## Validation evidence +- 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 From 5b3720f4e0fab1a5f891e1f51b4c1d83825bbade Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Sat, 24 Oct 2026 00:42:43 -0600 Subject: [PATCH 35/38] chore: prepare 1.0.0 release --- RELEASE_CHECKLIST.md | 50 +++++++++++++++++++++++++++----------------- docs/DECISIONS.md | 2 ++ docs/STATUS.md | 46 ++++++++++++++++++++++++++++------------ pyproject.toml | 2 +- uv.lock | 2 +- 5 files changed, 68 insertions(+), 34 deletions(-) diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 942e8a1..8f1ed13 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -1,32 +1,37 @@ # Release checklist -Target release tag: `v1.0.0rc1`. Python package version: `1.0.0rc1`. +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 release gate without the development-only -`--with-editable ../ctui` override. Confirm `pyproject.toml` and `uv.lock` use -the intended ctui release from PyPI before starting. +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 black --check src tests` -- [ ] `uv run isort --check-only src tests` -- [ ] `uv run pylint src/ctmodbus` -- [ ] `uv run python -m unittest discover -s tests -v` +- [ ] `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 --isolated --no-project --with dist/ARTIFACT tests/smoke_test.py`. + `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. -- [ ] Before final 1.0.0: adopt the published stable ctui 1.0 release, regenerate - the lockfile, update ctmodbus version and changelog, and rerun all gates. - [ ] Publish only after release approval; creating/pushing a version tag triggers the existing trusted-PyPI publishing workflow. @@ -38,24 +43,31 @@ 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 a `## [1.0.0rc1]` section and set its release date. - Release notes select the exact version first, falling back to the base version - for prereleases. Missing sections fail before publication. +- [ ] 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 matching annotated tag: +- [ ] After release approval, create and push the annotated tag on the validated + `main` release revision: ```bash - git tag -a v1.0.0rc1 -m "ctmodbus 1.0.0rc1" - git push origin v1.0.0rc1 + 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.0rc1` must be a prerelease, not latest. + 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.0rc1` in a new environment and check help and a device workflow. + `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/docs/DECISIONS.md b/docs/DECISIONS.md index 42595dc..6b9b9b0 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -153,6 +153,8 @@ 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 diff --git a/docs/STATUS.md b/docs/STATUS.md index 3042979..dfdbd39 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,16 +1,22 @@ # Project status -Last reconciled: 2026-10-24, `dev-v1.0.0` at `68d7aa3`, with an uncommitted -1.0.0 changelog update dated October 24. The README and server-guide -consolidation are committed; validation for the changelog is recorded below. +Last reconciled: 2026-10-24, `dev-v1.0.0` at `08de58b`, with uncommitted stable +1.0.0 package/lockfile and release-checklist changes. The dated changelog and +README are committed; local stable-version release checks below pass. ## Current state - 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 and the release checklist still target RC1; - updating them and running final release gates remain next steps. The dated - changelog does not establish publication. + implementation. Package metadata, lockfile and release checklist now target + stable 1.0.0. Local checks 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`. Commit/push the remaining changes when authorized, open the PR with + validation evidence, then verify the final `main` revision before approving + and pushing `v1.0.0`. The checklist records this sequence; no PR or merge has + been performed during local release preparation. - README uses the owner's approved informal, concise technical style throughout. The feature list distinguishes flexible reads from consecutive writes and @@ -33,7 +39,7 @@ consolidation are committed; validation for the changelog is recorded below. - 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 remains `1.0.0rc1`; dependencies are `ctui==1.0.0` and +- 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` @@ -135,12 +141,11 @@ consolidation are committed; validation for the changelog is recorded below. 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. Update ctmodbus stable version metadata, lockfile and release checklist; - the 1.0.0 changelog is dated October 24 and the ctui dependency already uses - published stable 1.0.0. Run the - [release checklist](../RELEASE_CHECKLIST.md) on the intended release revision, - including the full remote platform matrix, artifacts, terminal/browser - checks, and physical RTU/ASCII devices. Record actual evidence here. +3. Commit/push release preparation when authorized and open a PR from + `dev-v1.0.0` into `main`. Require the full platform/Python matrix, review and + merge the PR, then run the [release checklist](../RELEASE_CHECKLIST.md) on + the intended `main` release revision. Local branch results below do not + establish final-main or remote validation. 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). @@ -151,6 +156,21 @@ release readiness lacks final-revision and manual/remote validation. ## Validation evidence +- 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. diff --git a/pyproject.toml b/pyproject.toml index f5ca934..dcea7de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ctmodbus" -version = "1.0.0rc1" +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.11,<4" diff --git a/uv.lock b/uv.lock index dbcba1f..30e0d65 100644 --- a/uv.lock +++ b/uv.lock @@ -275,7 +275,7 @@ wheels = [ [[package]] name = "ctmodbus" -version = "1.0.0rc1" +version = "1.0.0" source = { editable = "." } dependencies = [ { name = "ctui" }, From 85f59b40a280d0092ac0c0cc7030ef8b812990ee Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Sat, 24 Oct 2026 01:25:08 -0600 Subject: [PATCH 36/38] test: fix cross-platform paths and polling timing --- docs/STATUS.md | 50 ++++++++++++++++-------- tests/test_polling.py | 89 ++++++++++++++++++++++++++++++++++++------- tests/test_server.py | 25 ++++++------ tests/test_tags.py | 25 +++++++----- 4 files changed, 141 insertions(+), 48 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index dfdbd39..178c901 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,11 +1,23 @@ # Project status -Last reconciled: 2026-10-24, `dev-v1.0.0` at `08de58b`, with uncommitted stable -1.0.0 package/lockfile and release-checklist changes. The dated changelog and -README are committed; local stable-version release checks below pass. +Last reconciled: 2026-10-24, `dev-v1.0.0` at `5b3720f`, with uncommitted CI +regression-test fixes. Stable metadata, release documentation and changelog are +committed and pushed; the corrected tests pass locally on Python 3.11 and 3.14. ## Current state +- The [release-preparation CI run](https://github.com/ControlThings-io/ctmodbus/actions/runs/40071325659) + at `5b3720f` failed 14 of 16 platform/Python test jobs. The recurring causes + are a polling test's strict real-time upper bound, unquoted Windows paths in + tag/server test commands, and a macOS hook-path assertion comparing resolved + and symlinked paths. Quality checks passed; artifact jobs were skipped because + tests failed. The preceding stable-ctui run showed the same failure patterns. +- Test fixes quote file paths with the framework's shell-like syntax, use + temporary directories containing spaces, and compare the canonical hook path. + Polling now has separate gated-I/O/lock checks and a virtual-clock cadence + regression instead of an exact real-time deadline. No runtime code or workflow + skips changed. Local results below pass; remote verification awaits submission. + - 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 @@ -13,7 +25,7 @@ README are committed; local stable-version release checks below pass. 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`. Commit/push the remaining changes when authorized, open the PR with + `main`. Commit/push the CI test fixes when authorized, open the PR with validation evidence, then verify the final `main` revision before approving and pushing `v1.0.0`. The checklist records this sequence; no PR or merge has been performed during local release preparation. @@ -46,14 +58,10 @@ README are committed; local stable-version release checks below pass. 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. -- October 23 release review confirmed ctui 1.0.0 on PyPI; ctmodbus remains - unpublished at 1.0 at that review. Existing - [CI at 9c15d08](https://github.com/ControlThings-io/ctmodbus/actions/runs/40051287256) - failed because the old ctui RC1 dependency lacks newer imports. The updated - dependency passes local tests/lint; remote CI after the dependency update - has not been reviewed here. - The GitHub `pypi` environment exists; PyPI trusted publisher configuration - still needs verification. +- Published ctui 1.0.0 fixes the earlier RC1 import incompatibility. The current + remote failures are the test assumptions described above. 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. @@ -141,7 +149,7 @@ README are committed; local stable-version release checks below pass. 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. Commit/push release preparation when authorized and open a PR from +3. Commit/push the CI test fixes when authorized and open a PR from `dev-v1.0.0` into `main`. Require the full platform/Python matrix, review and merge the PR, then run the [release checklist](../RELEASE_CHECKLIST.md) on the intended `main` release revision. Local branch results below do not @@ -151,11 +159,23 @@ README are committed; local stable-version release checks below pass. before publication (D08). Device cloning, MITM rules, raw/fuzzy requests, tunneling, and -historian integration remain deferred. No implementation blocker is established; -release readiness lacks final-revision and manual/remote validation. +historian integration remain deferred. Release readiness requires a green +remote matrix on the corrected revision, final-main validation and manual checks. ## Validation evidence +- 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 diff --git a/tests/test_polling.py b/tests/test_polling.py index e2cb69b..98e2439 100644 --- a/tests/test_polling.py +++ b/tests/test_polling.py @@ -86,24 +86,25 @@ async def test_tags_all_explicit_order_types_and_guards(self): await self.finish() self.assertIn("# | z | a | z\n1 | True | 1 | True", unpad(self.app.output_text)) - async def test_fixed_cadence_skips_inflight_and_manual_work(self): - """A slow response skips ticks without overlapping or adding response delay.""" - starts = [] - original = self.client.read_coils + 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 delayed(**kwargs): - starts.append(asyncio.get_running_loop().time()) - if len(starts) == 1: - await asyncio.sleep(0.14) - return await original(**kwargs) + 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) - self.client.read_coils = delayed - await self.app.dispatch("poll raw --coils 0 --interval 0.05 --count 3") + 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.assertLess(starts[1] - starts[0], 0.19) - self.assertGreater(starts[1] - starts[0], 0.13) 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" @@ -330,6 +331,68 @@ async def test_cli_interruption_propagates_to_dispatch(self): 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.""" diff --git a/tests/test_server.py b/tests/test_server.py index 297b4ab..1e0ca2b 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -3,6 +3,7 @@ import asyncio import copy import os +import shlex import shutil import socket import ssl @@ -195,7 +196,7 @@ class ServerTests(unittest.IsolatedAsyncioTestCase): """Use actual TCP/UDP clients and upstream servers; no external network traffic.""" async def asyncSetUp(self): - self.folder = tempfile.TemporaryDirectory() + self.folder = tempfile.TemporaryDirectory(prefix="ctmodbus server ") self.app = ModbusApp(data_dir=self.folder.name) await self.app.backend.open() self.clients = [] @@ -252,16 +253,16 @@ async def test_ui_configuration_export_and_import_confirmation(self): ): await self.app.dispatch(text) path = Path(self.folder.name) / "device.toml" - await self.app.dispatch(f"server config export {path}") + 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 {path}") + 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 {path}") + await self.app.dispatch(f"server config import {shlex.quote(str(path))}") async def approve(message): path.write_text(dumps(definition(False))) @@ -270,7 +271,7 @@ async def approve(message): return True await self.app.dispatch( - f"server config import {path}", confirm_callback=approve + f"server config import {shlex.quote(str(path))}", confirm_callback=approve ) self.assertEqual((await self.app.tags.get("level")).address, 20) self.assertEqual( @@ -428,8 +429,10 @@ async def test_hook_file_failure_rolls_back_and_tick_changes_values(self): 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 {path} on_write") - await self.app.dispatch(f"server hook tick {path} on_tick --interval 0.02") + 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) @@ -450,8 +453,8 @@ async def tick_done(): (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 {path_export}") - self.assertEqual(load(path_export)["hooks"]["module"], str(path)) + 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() @@ -525,7 +528,7 @@ async def test_native_tls_and_client_certificate_requirement(self): ) port = free_port() await self.app.dispatch( - f"server start tls 127.0.0.1 --port {port} --cert-file {cert} --key-file {key}" + 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( @@ -540,7 +543,7 @@ async def test_native_tls_and_client_certificate_requirement(self): 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 {cert} --key-file {key} --ca-file {cert}" + 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( diff --git a/tests/test_tags.py b/tests/test_tags.py index 2e94d27..3ecdfd1 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -1,6 +1,7 @@ """Typed tag persistence, conversion, I/O, and interchange tests.""" import asyncio +import shlex import sqlite3 import tempfile import unittest @@ -89,7 +90,7 @@ class TagCommandTests(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): """Open a fresh temporary project and inject a deterministic fake client.""" - self.directory = tempfile.TemporaryDirectory() + self.directory = tempfile.TemporaryDirectory(prefix="ctmodbus tags ") self.client = FakeClient() self.app = ModbusApp( data_dir=self.directory.name, client_factory=lambda settings: self.client @@ -292,16 +293,16 @@ 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 {path}") + 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 {exported}") + 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 {exported}") + await self.app.dispatch(f"tags import {shlex.quote(str(exported))}") messages = [] async def decline(message): @@ -310,11 +311,13 @@ async def decline(message): return False result = await self.app.dispatch( - f"tags import {exported}", confirm_callback=decline + 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 {exported} --replace") + 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): @@ -341,7 +344,9 @@ async def approve(message): await self.app.dispatch("help tags import") return True - await self.app.dispatch(f"tags imp {path}", confirm_callback=approve) + 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) @@ -388,7 +393,9 @@ async def approve(message): await asyncio.Event().wait() task = asyncio.create_task( - self.app.dispatch(f"tags import {path}", confirm_callback=approve) + self.app.dispatch( + f"tags import {shlex.quote(str(path))}", confirm_callback=approve + ) ) await asyncio.wait_for(started.wait(), 1) task.cancel() @@ -450,5 +457,5 @@ async def test_import_is_validated_before_changes(self): encoding="utf-8", ) with self.assertRaisesRegex(CommandError, "Invalid tag 'bad'"): - await self.app.dispatch(f"tags import {path}") + await self.app.dispatch(f"tags import {shlex.quote(str(path))}") self.assertEqual(await self.app.tags.list(), []) From b43c72e857e338b617a855211224eaaddc9a2b48 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Sat, 24 Oct 2026 01:32:39 -0600 Subject: [PATCH 37/38] test: stabilize Windows TLS fixture --- docs/STATUS.md | 44 ++++++++++++++++++++++++++++---------------- tests/test_server.py | 11 ++++++++--- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index 178c901..cb7a384 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,22 +1,23 @@ # Project status -Last reconciled: 2026-10-24, `dev-v1.0.0` at `5b3720f`, with uncommitted CI -regression-test fixes. Stable metadata, release documentation and changelog are -committed and pushed; the corrected tests pass locally on Python 3.11 and 3.14. +Last reconciled: 2026-10-24, `dev-v1.0.0` at `85f59b4`, with an uncommitted +Windows TLS-fixture fix. Prior path/cadence fixes pass remote Linux/macOS jobs; +the remaining TLS adjustment passes local checks below. ## Current state -- The [release-preparation CI run](https://github.com/ControlThings-io/ctmodbus/actions/runs/40071325659) - at `5b3720f` failed 14 of 16 platform/Python test jobs. The recurring causes - are a polling test's strict real-time upper bound, unquoted Windows paths in - tag/server test commands, and a macOS hook-path assertion comparing resolved - and symlinked paths. Quality checks passed; artifact jobs were skipped because - tests failed. The preceding stable-ctui run showed the same failure patterns. -- Test fixes quote file paths with the framework's shell-like syntax, use - temporary directories containing spaces, and compare the canonical hook path. - Polling now has separate gated-I/O/lock checks and a virtual-clock cadence - regression instead of an exact real-time deadline. No runtime code or workflow - skips changed. Local results below pass; remote verification awaits submission. +- The [latest CI run](https://github.com/ControlThings-io/ctmodbus/actions/runs/40074055336) + at `85f59b4` passes all 12 Linux/macOS platform/Python jobs and quality checks. + All four Windows jobs now fail only the first connection in + `test_native_tls_and_client_certificate_requirement`; prior path, symlink and + polling fixes pass. Artifact jobs remain skipped because Windows tests fail. +- The TLS fixture connected to `localhost` with a 0.5-second timeout while the + listener bound IPv4. Logs show a timeout-compatible empty connection error; + resolver/address-family latency is suspected, not proven on Windows. A local + controlled resolver delay reproduces the same failure. The fixture now uses + IPv4 directly, a matching certificate IP SAN and the normal three-second + timeout, with certificate verification enabled. Runtime code and workflow + skips are unchanged; Windows verification awaits submission. - 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 @@ -25,7 +26,7 @@ committed and pushed; the corrected tests pass locally on Python 3.11 and 3.14. 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`. Commit/push the CI test fixes when authorized, open the PR with + `main`. Commit/push the remaining TLS test fix when authorized, open the PR with validation evidence, then verify the final `main` revision before approving and pushing `v1.0.0`. The checklist records this sequence; no PR or merge has been performed during local release preparation. @@ -149,7 +150,7 @@ committed and pushed; the corrected tests pass locally on Python 3.11 and 3.14. 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. Commit/push the CI test fixes when authorized and open a PR from +3. Commit/push the TLS test fix when authorized and open a PR from `dev-v1.0.0` into `main`. Require the full platform/Python matrix, review and merge the PR, then run the [release checklist](../RELEASE_CHECKLIST.md) on the intended `main` release revision. Local branch results below do not @@ -164,6 +165,17 @@ remote matrix on the corrected revision, final-main validation and manual checks ## Validation evidence +- 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. diff --git a/tests/test_server.py b/tests/test_server.py index 1e0ca2b..3143137 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -500,6 +500,11 @@ async def test_bind_failure_and_nonblocking_start(self): @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", @@ -521,7 +526,7 @@ async def test_native_tls_and_client_certificate_requirement(self): "-subj", "/CN=localhost", "-addext", - "subjectAltName=DNS:localhost", + "subjectAltName=DNS:localhost,IP:127.0.0.1", ], check=True, capture_output=True, @@ -532,7 +537,7 @@ async def test_native_tls_and_client_certificate_requirement(self): ) context = ssl.create_default_context(cafile=str(cert)) client = AsyncModbusTlsClient( - "localhost", port=port, sslctx=context, timeout=0.5, retries=0 + "127.0.0.1", port=port, sslctx=context, timeout=3, retries=0 ) self.clients.append(client) self.assertTrue(await client.connect()) @@ -547,7 +552,7 @@ async def test_native_tls_and_client_certificate_requirement(self): ) context.load_cert_chain(str(cert), str(key)) client = AsyncModbusTlsClient( - "localhost", port=port, sslctx=context, timeout=0.5, retries=0 + "127.0.0.1", port=port, sslctx=context, timeout=3, retries=0 ) self.clients.append(client) self.assertTrue(await client.connect()) From 4d29571d2c6dac8002631cf1d9ced06a6e525e22 Mon Sep 17 00:00:00 2001 From: Justin Searle Date: Sat, 24 Oct 2026 01:52:11 -0600 Subject: [PATCH 38/38] docs: record passing release CI --- docs/STATUS.md | 62 ++++++++++++++++++++++++++------------------------ 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index cb7a384..cb30eed 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,35 +1,31 @@ # Project status -Last reconciled: 2026-10-24, `dev-v1.0.0` at `85f59b4`, with an uncommitted -Windows TLS-fixture fix. Prior path/cadence fixes pass remote Linux/macOS jobs; -the remaining TLS adjustment passes local checks below. +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/40074055336) - at `85f59b4` passes all 12 Linux/macOS platform/Python jobs and quality checks. - All four Windows jobs now fail only the first connection in - `test_native_tls_and_client_certificate_requirement`; prior path, symlink and - polling fixes pass. Artifact jobs remain skipped because Windows tests fail. -- The TLS fixture connected to `localhost` with a 0.5-second timeout while the - listener bound IPv4. Logs show a timeout-compatible empty connection error; - resolver/address-family latency is suspected, not proven on Windows. A local - controlled resolver delay reproduces the same failure. The fixture now uses - IPv4 directly, a matching certificate IP SAN and the normal three-second - timeout, with certificate verification enabled. Runtime code and workflow - skips are unchanged; Windows verification awaits submission. +- 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. Local checks and artifact smoke tests pass; PR/main CI and manual + 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`. Commit/push the remaining TLS test fix when authorized, open the PR with - validation evidence, then verify the final `main` revision before approving - and pushing `v1.0.0`. The checklist records this sequence; no PR or merge has - been performed during local release preparation. + `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 @@ -59,9 +55,8 @@ the remaining TLS adjustment passes local checks below. 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 current - remote failures are the test assumptions described above. The GitHub `pypi` - environment exists; PyPI trusted publisher configuration still needs +- 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 @@ -150,21 +145,28 @@ the remaining TLS adjustment passes local checks below. 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. Commit/push the TLS test fix when authorized and open a PR from - `dev-v1.0.0` into `main`. Require the full platform/Python matrix, review and - merge the PR, then run the [release checklist](../RELEASE_CHECKLIST.md) on - the intended `main` release revision. Local branch results below do not - establish final-main or remote validation. Record actual evidence here. +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. Release readiness requires a green -remote matrix on the corrected revision, final-main validation and manual checks. +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