diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..65d04ff --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(dir:*)", + "Bash(findstr:*)", + "Bash(make:*)", + "Bash(uv sync:*)", + "WebFetch(domain:raw.githubusercontent.com)" + ] + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b2ebd91 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +## 0.1.0 - 2026-02-26 + +### Added + +- v0.1 documentation structure with capability-oriented API reference pages: + - Client API + - Service API + - Server API + - Files API + - Addressing API + - Validation API + - Advanced API +- New usage guides: + - `guides/types.md` for native Python value contracts + - `guides/addressing.md` for canonical normalized addressing and XD/YD details +- API reference coverage guard in `docs/gen_reference.py` to ensure all exported symbols in `pyclickplc.__all__` are documented exactly once. + +### Changed + +- README restructured for v0.1 launch: + - clearer async (`ClickClient`) vs sync (`ModbusService`) entry points + - native Python type contract table + - explicit error model (`ValueError` validation vs `OSError` transport) + - `XD0u` / `YD0u` moved out of quickstart to addressing guidance + - stability policy documented as "stable core, evolving edges" +- MkDocs navigation updated to capability-oriented API pages and new core guides. + +### Notes + +- Stable core APIs are documented in primary navigation. +- Lower-level Modbus mapping and bank metadata APIs are documented under Advanced API and may evolve more quickly. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b96a582 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,69 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +`pyclickplc` is a shared utility library for AutomationDirect CLICK PLCs. It provides PLC bank definitions, address parsing, nickname CSV and DataView CDV file I/O, BlockTag parsing, Modbus protocol mapping, and async Modbus TCP client/server. Consumed by ClickNick (GUI editor), pyrung (simulation), and standalone tooling. + +## Commands + +- **Install**: `uv sync --all-extras --dev` +- **Run all (install + lint + test)**: `make` or `make default` +- **Test**: `make test` (runs `uv run pytest`) +- **Single test**: `uv run pytest tests/test_addresses.py::TestParseAddress::test_xd_basic -v` +- **Lint**: `make lint` (runs `uv run devtools/lint.py` — codespell, ruff check --fix, ruff format, ty check) +- **Build**: `make build` (runs `uv build`) + +## Architecture + +### Two-Layer Design + +PLC knowledge and Modbus protocol are separated into two layers linked by bank name: + +1. **`banks.py`** (foundation, zero deps) — `BankConfig`, `BANKS` dict, `DataType` enum, sparse `valid_ranges` for X/Y hardware slots, address validation +2. **`modbus.py`** (depends on banks) — `ModbusMapping`, `MODBUS_MAPPINGS` dict, forward/reverse address mapping, register pack/unpack + +### Address System + +All address parsing flows through `parse_address()` in `addresses.py`, which returns `(memory_type, mdb_address)` — MDB-style indices for all banks. XD/YD use contiguous MDB indices 0-16; display addresses 0-8 map via `xd_yd_display_to_mdb()`. The `format_address_display()` function handles the reverse. These two functions are the canonical formatting/parsing entry points used by client, server, nicknames, and dataview modules. + +### Dependency Graph + +``` +banks.py ← no deps (foundation) + ↑ + ├── validation.py + ├── addresses.py + │ ↑ + │ ├── blocks.py + │ ├── dataview.py + │ ├── modbus.py + │ │ ↑ + │ │ ├── client.py ──→ nicknames.py + │ │ └── server.py + │ └── nicknames.py ──→ blocks.py, validation.py +``` + +### Key Modules + +- **`client.py`** — `ClickClient` (async Modbus TCP client using pymodbus) with `AddressAccessor`, `AddressInterface`, `TagInterface` +- **`server.py`** — `ClickServer` (async Modbus TCP server) with `DataProvider` protocol and `MemoryDataProvider` +- **`nicknames.py`** — Read/write CLICK nickname CSV files +- **`dataview.py`** — Read/write DataView CDV files (UTF-16 LE CSV format) +- **`blocks.py`** — BlockTag parsing and computation + +### Modbus Mapping + +`plc_to_modbus(bank, index)` uses `base + index` for 0-based banks (XD/YD) and `base + width * (index - 1)` for 1-based banks. X/Y are sparse coil banks with slot-based offset formulas. `modbus_to_plc()` reverses the mapping. + +## Conventions + +- `DataType` enum is the single source of truth for PLC types; Modbus properties derive from it +- All dataclasses use `frozen=True` +- Tests in `tests/` mirror source modules (e.g., `test_addresses.py` tests `addresses.py`) +- pytest discovers tests from both `src/` and `tests/` directories (`python_files = ["*.py"]`) +- `asyncio_mode = "auto"` — async tests need no decorator +- Package uses src-layout: source is in `src/pyclickplc/` +- Python 3.11+ required; CI tests 3.11-3.14 +- Ruff line-length is 100 diff --git a/Makefile b/Makefile index 0f355f7..01acba3 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ .DEFAULT_GOAL := default -.PHONY: default install lint test upgrade build clean docs docs-check +.PHONY: default install lint test upgrade build clean docs-serve docs-build docs-check default: install lint test @@ -23,6 +23,14 @@ upgrade: build: uv build +docs-serve: + uv run --group docs mkdocs serve + +docs-build: + uv run --group docs mkdocs build --strict + +docs-check: docs-build + # Improved Windows detection ifeq ($(OS),Windows_NT) WINDOWS := 1 diff --git a/README.md b/README.md index 4f6d5ba..f5eca3a 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,133 @@ -# Pyrung +# pyclickplc -Utilities to work with AutomationDirect CLICK Plcs +Utilities for AutomationDirect CLICK PLCs: Modbus TCP client/server, address helpers, nickname CSV I/O, and DataView CDV I/O. -## Status +Documentation: https://ssweber.github.io/pyclickplc/ +LLM docs index: https://ssweber.github.io/pyclickplc/llms.txt +LLM full context: https://ssweber.github.io/pyclickplc/llms-full.txt -PLANNING ONLY * INITIAL COMMIT +## Installation -## Goals +```bash +uv add pyclickplc +# or +pip install pyclickplc +``` -- Provide shared utility library for reading/writing Nickname csv files, -Dataview .cdv file, communicating via Modbus, and parsing the BlockTag comment specification +Requires Python 3.11+. Modbus client/server functionality depends on [pymodbus](https://github.com/pymodbus-dev/pymodbus). + +## Choose Your Interface + +- `ClickClient`: async API for direct `asyncio` applications. +- `ModbusService`: sync + polling API for UI/event-driven applications that do not want to manage an async loop. + +## Native Python Type Contract + +All read/write APIs operate on native Python values. + +| Bank family | Python type | Examples | +| --- | --- | --- | +| `X`, `Y`, `C`, `T`, `CT`, `SC` | `bool` | `True`, `False` | +| `DS`, `DD`, `DH`, `TD`, `CTD`, `SD`, `XD`, `YD` | `int` | `42`, `0x1234` | +| `DF` | `float` | `3.14` | +| `TXT` | `str` | `"A"` | + +`read()` methods return `ModbusResponse`, keyed by canonical normalized addresses (`"DS1"`, `"X001"`): + +- lookups are normalized (`resp["ds1"]` resolves `"DS1"`) +- single index reads like `await plc.ds[1]` return a bare native Python value + +## Quickstart (`ClickClient`, async) + +```python +import asyncio +from pyclickplc import ClickClient, read_csv + +tags = read_csv("nicknames.csv") + +async def main(): + async with ClickClient("192.168.1.10", 502, tags=tags) as plc: + # Bank accessors + await plc.ds.write(1, 100) + ds1 = await plc.ds[1] + df_values = await plc.df.read(1, 3) + await plc.y.write(1, True) + + # String address interface + await plc.addr.write("df1", 3.14) + df1 = await plc.addr.read("DF1") + + # Tag interface (case-insensitive) + await plc.tag.write("mytag", 42.0) + tag_value = await plc.tag.read("MyTag") + + print(ds1, df_values, df1, tag_value) + +asyncio.run(main()) +``` + +## `ModbusService` (sync + polling) + +```python +from pyclickplc import ModbusService, ReconnectConfig + +def on_values(values): + print(values) # ModbusResponse keyed by canonical normalized addresses + +svc = ModbusService( + poll_interval_s=0.5, + reconnect=ReconnectConfig(delay_s=0.5, max_delay_s=5.0), + on_values=on_values, +) +svc.connect("192.168.1.10", 502, device_id=1, timeout=1) + +svc.set_poll_addresses(["DS1", "DF1", "Y1"]) +print(svc.read(["DS1", "DF1"])) +print(svc.write({"DS1": 100, "Y1": True, "X1": True})) # X1 is not writable + +svc.disconnect() +``` + +## Error Model + +- Validation and address parsing failures raise `ValueError`. +- Transport/protocol failures raise `OSError` for reads. +- `ModbusService.write()` returns per-address outcomes (`ok` + `error`) instead of raising per-item validation failures. + +## Addressing Nuances + +- Address strings are canonical normalized (`x1` -> `X001`, `ds1` -> `DS1`). +- `X`/`Y` are sparse address families; not every numeric value is valid. +- `XD`/`YD` accessors are display-indexed (`0..8`) by default. +- `XD0u`/`YD0u` are explicit upper-byte aliases for advanced use cases. + +See the addressing guide for details: +- https://ssweber.github.io/pyclickplc/guides/addressing/ + +## Other Features + +- Modbus simulator: `ClickServer`, `MemoryDataProvider`, `run_server_tui` +- CLICK nickname CSV I/O: `read_csv`, `write_csv`, `AddressRecordMap` +- CLICK DataView CDV I/O: `read_cdv`, `write_cdv`, `verify_cdv`, `check_cdv_file` + +Server docs: +- Guide: https://ssweber.github.io/pyclickplc/guides/server/ +- API: https://ssweber.github.io/pyclickplc/reference/api/server/ + +## v0.1 API Stability + +`pyclickplc` v0.1 follows a “stable core, evolving edges” policy. + +- Stable core: client/service/server/file I/O/address/validation APIs surfaced in the docs site primary navigation. +- Advanced/evolving: low-level Modbus mapping helpers and bank metadata (`ModbusMapping`, `plc_to_modbus`, `BANKS`, etc.). + +## Development + +```bash +uv sync --all-extras --dev # Install dependencies +make test # Run tests (uv run pytest) +make lint # Lint (codespell, ruff, ty) +make docs-build # Build docs (mkdocs + mkdocstrings) +make docs-serve # Serve docs locally +make # All of the above +``` diff --git a/docs/gen_reference.py b/docs/gen_reference.py new file mode 100644 index 0000000..d949a63 --- /dev/null +++ b/docs/gen_reference.py @@ -0,0 +1,191 @@ +"""Generate curated MkDocs API reference pages for pyclickplc public exports.""" + +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass +from importlib import import_module +from pathlib import Path + +import mkdocs_gen_files + +PACKAGE = "pyclickplc" + + +@dataclass(frozen=True) +class ReferencePage: + slug: str + title: str + tier: str + summary: str + symbols: tuple[str, ...] + + +PAGES: tuple[ReferencePage, ...] = ( + ReferencePage( + slug="client", + title="Client API", + tier="Stable Core", + summary="Async client and response mapping APIs.", + symbols=("ClickClient", "ModbusResponse"), + ), + ReferencePage( + slug="service", + title="Service API", + tier="Stable Core", + summary="Synchronous service wrapper and polling lifecycle APIs.", + symbols=("ModbusService", "ReconnectConfig", "ConnectionState", "WriteResult"), + ), + ReferencePage( + slug="server", + title="Server API", + tier="Stable Core", + summary="CLICK Modbus TCP simulator and server utilities.", + symbols=("ClickServer", "MemoryDataProvider", "ServerClientInfo", "run_server_tui"), + ), + ReferencePage( + slug="files", + title="Files API", + tier="Stable Core", + summary="Nickname CSV and DataView CDV models and file I/O helpers.", + symbols=( + "read_csv", + "write_csv", + "AddressRecordMap", + "read_cdv", + "write_cdv", + "verify_cdv", + "check_cdv_file", + "DataViewFile", + "DataViewRecord", + "get_data_type_for_address", + "validate_new_value", + ), + ), + ReferencePage( + slug="addressing", + title="Addressing API", + tier="Stable Core", + summary="Address model and canonical normalized address helpers.", + symbols=("AddressRecord", "parse_address", "normalize_address", "format_address_display"), + ), + ReferencePage( + slug="validation", + title="Validation API", + tier="Stable Core", + summary="Nickname/comment/initial-value validators and system nickname constants.", + symbols=( + "SYSTEM_NICKNAME_TYPES", + "validate_nickname", + "validate_comment", + "validate_initial_value", + ), + ), + ReferencePage( + slug="advanced", + title="Advanced API", + tier="Advanced / Evolving", + summary="Lower-level bank metadata and Modbus mapping helpers.", + symbols=( + "BANKS", + "BankConfig", + "DataType", + "ModbusMapping", + "plc_to_modbus", + "modbus_to_plc", + "pack_value", + "unpack_value", + ), + ), +) + + +def _validate_manifest() -> None: + exported = set(import_module(PACKAGE).__all__) + assigned = [symbol for page in PAGES for symbol in page.symbols] + counts = Counter(assigned) + + duplicates = sorted(symbol for symbol, count in counts.items() if count > 1) + assigned_set = set(counts) + missing = sorted(exported - assigned_set) + extra = sorted(assigned_set - exported) + + if not (duplicates or missing or extra): + return + + parts: list[str] = ["API reference manifest does not match pyclickplc.__all__."] + if duplicates: + parts.append(f"Duplicate symbols: {', '.join(duplicates)}") + if missing: + parts.append(f"Missing exported symbols: {', '.join(missing)}") + if extra: + parts.append(f"Unknown symbols not exported: {', '.join(extra)}") + raise RuntimeError(" ".join(parts)) + + +def _write_reference_page(page: ReferencePage) -> None: + doc_rel_path = Path("reference/api") / f"{page.slug}.md" + lines = [ + f"# {page.title}", + "", + f"**Tier:** {page.tier}", + "", + page.summary, + "", + ] + if page.slug == "client": + lines.extend( + [ + "## Client Surface", + "", + "- Dynamic bank accessors: `plc.ds`, `plc.df`, `plc.y`, `plc.txt`, etc.", + "- Display-indexed accessors: `plc.xd` and `plc.yd` use display indices `0..8`.", + "- Upper-byte aliases: `plc.xd0u` and `plc.yd0u` expose `XD0u` / `YD0u`.", + "- String-address interface: `plc.addr.read(...)` and `plc.addr.write(...)`.", + "- Nickname/tag interface: `plc.tag.read(...)`, `plc.tag.write(...)`, and `plc.tag.read_all(...)`.", + "", + "Because accessor attributes are dynamic, this section is hand-curated and complements docstring-generated signatures below.", + "", + ] + ) + for symbol in page.symbols: + lines.append(f"::: {PACKAGE}.{symbol}") + lines.append("") + + with mkdocs_gen_files.open(doc_rel_path, "w") as fd: + fd.write("\n".join(lines).rstrip() + "\n") + mkdocs_gen_files.set_edit_path(doc_rel_path, Path("docs/gen_reference.py")) + + +def _write_index() -> None: + stable_pages = [page for page in PAGES if page.tier == "Stable Core"] + advanced_pages = [page for page in PAGES if page.tier != "Stable Core"] + lines = [ + "# API Reference", + "", + "This section is generated from an explicit, versioned public API manifest.", + "", + "## Stability Policy", + "", + "- Stable core pages document v0.1 compatibility commitments.", + "- Advanced API pages document lower-level helpers that may evolve faster.", + "", + "## Stable Core Pages", + "", + ] + for page in stable_pages: + lines.append(f"- [{page.title}](api/{page.slug}.md)") + + lines.extend(["", "## Advanced Pages", ""]) + for page in advanced_pages: + lines.append(f"- [{page.title}](api/{page.slug}.md)") + + with mkdocs_gen_files.open("reference/index.md", "w") as fd: + fd.write("\n".join(lines).rstrip() + "\n") + mkdocs_gen_files.set_edit_path("reference/index.md", Path("docs/gen_reference.py")) + + +_validate_manifest() +for ref_page in PAGES: + _write_reference_page(ref_page) +_write_index() diff --git a/docs/guides/addressing.md b/docs/guides/addressing.md new file mode 100644 index 0000000..4fd5a55 --- /dev/null +++ b/docs/guides/addressing.md @@ -0,0 +1,45 @@ +# Addressing + +`pyclickplc` uses canonical normalized address strings across APIs. + +## Canonical Normalized Addresses + +- Address parsing is case-insensitive. +- Normalization returns canonical display format. + +Examples: + +```python +from pyclickplc import normalize_address, parse_address + +assert normalize_address("x1") == "X001" +assert normalize_address("ds1") == "DS1" +assert normalize_address("xd0u") == "XD0u" + +assert parse_address("X001") == ("X", 1) +assert parse_address("XD0u") == ("XD", 1) # MDB index +``` + +## Sparse `X` and `Y` Ranges + +`X` and `Y` are sparse banks. Not every numeric value is valid (for example `X017` is invalid). + +Use parser/normalizer helpers to validate before building dynamic address lists. + +## `XD` and `YD` Display-Indexed Access + +- `plc.xd` and `plc.yd` are display-indexed (`0..8`). +- `plc.addr.read("XD0-XD4")` and `plc.addr.read("YD0-YD2")` use display-step ranges. +- Hidden odd MDB slots are not exposed in display-indexed range reads. + +## `XD0u` and `YD0u` (Upper-Byte Aliases) + +`XD0u` and `YD0u` are explicit upper-byte addresses and advanced edge cases: + +- Use `plc.xd0u.read()` / `plc.yd0u.write(...)` for direct alias access. +- `XD`/`YD` ranges cannot include `u` addresses (for example `XD0-XD0u` is invalid). + +## See Also + +- Type and value contract: [`guides/types.md`](types.md) +- Full API contracts: `API Reference -> Addressing API` diff --git a/docs/guides/client.md b/docs/guides/client.md new file mode 100644 index 0000000..9c5ca63 --- /dev/null +++ b/docs/guides/client.md @@ -0,0 +1,32 @@ +# Modbus Client + +`ClickClient` is the primary runtime API for reading and writing PLC values. + +```python +import asyncio +from pyclickplc import ClickClient + +async def main(): + async with ClickClient("192.168.1.10", 502) as plc: + await plc.ds.write(1, 100) + one_value = await plc.ds[1] + many_values = await plc.ds.read(1, 5) + + await plc.addr.write("df1", 3.14) + df1 = await plc.addr.read("df1") + +asyncio.run(main()) +``` + +## Access Patterns + +- Bank accessors: `plc.ds`, `plc.df`, `plc.y`, etc. +- String addresses: `plc.addr.read("DS1")`, `plc.addr.write("C1", True)` +- Tags (with `tags=`): `plc.tag.read("name")`, `plc.tag.write("name", value)` (case-insensitive) + +## Related Guides + +- Native value and bank typing rules: [`guides/types.md`](types.md) +- Canonical normalized addressing and XD/YD details: [`guides/addressing.md`](addressing.md) +- Full API signatures: `API Reference -> Client API` + diff --git a/docs/guides/files.md b/docs/guides/files.md new file mode 100644 index 0000000..194ad62 --- /dev/null +++ b/docs/guides/files.md @@ -0,0 +1,38 @@ +# File I/O + +## Nickname CSV + +```python +from pyclickplc import read_csv, write_csv + +records = read_csv("nicknames.csv") +motor = records.addr["ds1"] +tag = records.tag["mytag"] +count = write_csv("output.csv", records) +``` + +## DataView CDV + +```python +from pyclickplc import read_cdv, write_cdv + +dataview = read_cdv("dataview.cdv") +write_cdv("output.cdv", dataview) +``` + +## Address Helpers + +```python +from pyclickplc import format_address_display, normalize_address, parse_address + +bank, index = parse_address("X001") # ("X", 1) +display = format_address_display("X", 1) # "X001" +normalized = normalize_address("x1") # "X001" +``` + +## Related Guides + +- Address semantics and edge cases: [`guides/addressing.md`](addressing.md) +- Native value contract: [`guides/types.md`](types.md) +- Full API signatures: `API Reference -> Files API` + diff --git a/docs/guides/modbus_service.md b/docs/guides/modbus_service.md new file mode 100644 index 0000000..2f48349 --- /dev/null +++ b/docs/guides/modbus_service.md @@ -0,0 +1,49 @@ +# Modbus Service + +`ModbusService` provides a synchronous API on top of `ClickClient` for UI and service callers that do not want to manage `asyncio` directly. + +```python +from pyclickplc import ModbusService, ReconnectConfig + +def on_values(values): + # Runs on the service thread. + # GUI apps should marshal this callback to the UI thread. + print(values) + +svc = ModbusService( + poll_interval_s=0.5, + reconnect=ReconnectConfig(delay_s=0.5, max_delay_s=5.0), # optional + on_values=on_values, +) +svc.connect("192.168.1.10", 502, device_id=1, timeout=1) + +svc.set_poll_addresses(["DS1", "DF1", "Y1"]) +latest = svc.read(["DS1", "DF1"]) +write_results = svc.write({"DS1": 10, "Y1": True}) + +svc.close() # same as disconnect() +``` + +## API Notes + +- `set_poll_addresses(addresses)` replaces the active poll set. +- `clear_poll_addresses()` clears the set. +- `stop_polling()` pauses polling until `set_poll_addresses(...)` is called again. +- `read(...)` returns `ModbusResponse` keyed by canonical uppercase addresses. +- `write(...)` accepts either a mapping or iterable of `(address, value)` pairs and returns per-address results. +- `disconnect()` (or `close()`) fully stops the background service loop/thread. +- The next sync call (`connect`, `read`, `write`, etc.) will start the loop again. +- `reconnect=ReconnectConfig(...)` controls ClickClient auto-reconnect backoff. + +## Error Semantics and Thread Safety + +- Invalid addresses/values at write-time are returned per address with `ok=False`. +- Invalid addresses passed to `read(...)` raise `ValueError`. +- Transport/protocol errors raise `OSError` for reads and are reported per-address for writes. +- `on_state` and `on_values` callbacks run on the service thread. +- Do not call synchronous service methods (`connect`, `disconnect`, `read`, `write`, poll config methods) from these callbacks. Marshal work to another thread/UI loop. + +## Related Guides + +- Native value rules and write validation: [`guides/types.md`](types.md) +- Address parsing/normalization rules: [`guides/addressing.md`](addressing.md) diff --git a/docs/guides/server.md b/docs/guides/server.md new file mode 100644 index 0000000..3369ed8 --- /dev/null +++ b/docs/guides/server.md @@ -0,0 +1,58 @@ +# Modbus Server + +`ClickServer` simulates a CLICK PLC over Modbus TCP. + +```python +import asyncio +from pyclickplc import ClickServer, MemoryDataProvider + +async def main(): + provider = MemoryDataProvider() + provider.bulk_set( + { + "DS1": 42, + "Y001": True, + } + ) + + async with ClickServer(provider, host="localhost", port=5020): + await asyncio.sleep(60) + +asyncio.run(main()) +``` + +`MemoryDataProvider` helper methods: + +- `get(address)` +- `set(address, value)` +- `bulk_set({...})` + +## Interactive TUI + +Use `run_server_tui` for a basic interactive terminal interface: + +```python +import asyncio +from pyclickplc import ClickServer, MemoryDataProvider, run_server_tui + +async def main(): + provider = MemoryDataProvider() + server = ClickServer(provider, host="127.0.0.1", port=5020) + await run_server_tui(server) + +asyncio.run(main()) +``` + +Supported commands: + +- `help` +- `status` +- `clients` +- `disconnect ` +- `disconnect all` +- `shutdown` (`exit` / `quit`) + +## Related Reference + +- `API Reference -> Server API` + diff --git a/docs/guides/types.md b/docs/guides/types.md new file mode 100644 index 0000000..2676f3a --- /dev/null +++ b/docs/guides/types.md @@ -0,0 +1,36 @@ +# Native Value Types + +`pyclickplc` reads and writes native Python values. + +## Bank Family to Python Type + +| Bank family | Python type | Example | +| --- | --- | --- | +| `X`, `Y`, `C`, `T`, `CT`, `SC` | `bool` | `True` | +| `DS`, `DD`, `DH`, `TD`, `CTD`, `SD`, `XD`, `YD` | `int` | `42`, `0x1234` | +| `DF` | `float` | `3.14` | +| `TXT` | `str` | `"A"` | + +## Read Return Shapes + +- `read()` returns `ModbusResponse` keyed by canonical normalized addresses. +- Mapping lookups are normalized (`resp["ds1"]` resolves `DS1`). +- Single index access like `await plc.ds[1]` returns a bare native Python value. + +## Write Validation Rules + +- Type mismatches raise `ValueError` (for example writing `str` to `DS`). +- Out-of-range values raise `ValueError`. +- Non-writable addresses raise `ValueError`. +- Transport/protocol failures raise `OSError`. + +## `ModbusService` Write Semantics + +- `read(...)` invalid addresses raise `ValueError`. +- `read(...)` transport failures raise `OSError`. +- `write(...)` returns per-address `WriteResult` entries with `ok` and `error`. + +## See Also + +- Address semantics and normalization: [`guides/addressing.md`](addressing.md) +- Full API contracts: `API Reference -> Client API` and `Service API` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..f95ae04 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,28 @@ +# pyclickplc Docs + +`pyclickplc` provides tools for AutomationDirect CLICK PLCs: + +- Async Modbus TCP client (`ClickClient`) +- Sync + polling Modbus service (`ModbusService`) +- Modbus TCP simulator (`ClickServer`, `MemoryDataProvider`) +- Address parsing/normalization helpers +- CLICK nickname CSV and DataView CDV file I/O + +## Start Here + +- Use the `Core Usage` guides for task-oriented docs. +- Use `API Reference` for the versioned public API surface. +- `Advanced API` documents lower-level helpers that may evolve more quickly. + +## v0.1 Stability Policy + +- Stable core: client/service/server/file I/O/address/validation APIs. +- Evolving edges: low-level Modbus mapping helpers and bank metadata. + +## Local Docs Commands + +```bash +make docs-serve +make docs-build +``` + diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..396f3a5 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,55 @@ +site_name: pyclickplc +site_description: Documentation for pyclickplc +repo_url: https://github.com/ssweber/pyclickplc +site_url: https://ssweber.github.io/pyclickplc/ + +theme: + name: material + +nav: + - Home: index.md + - Core Usage: + - Modbus Client: guides/client.md + - Native Value Types: guides/types.md + - Addressing: guides/addressing.md + - Modbus Service: guides/modbus_service.md + - Modbus Server: guides/server.md + - File I/O: guides/files.md + - API Reference: + - Overview: reference/index.md + - Client API: reference/api/client.md + - Service API: reference/api/service.md + - Server API: reference/api/server.md + - Files API: reference/api/files.md + - Addressing API: reference/api/addressing.md + - Validation API: reference/api/validation.md + - Advanced API: reference/api/advanced.md + +plugins: + - search + - gen-files: + scripts: + - docs/gen_reference.py + - mkdocstrings: + handlers: + python: + paths: + - src + options: + filters: + - "!^_" + heading_level: 2 + inherited_members: false + members_order: source + separate_signature: true + show_root_heading: true + show_signature_annotations: true + show_source: false + - llmstxt: + full_output: llms-full.txt + sections: + Core Usage: + - guides/*.md + API Reference: + - reference/index.md + - reference/api/*.md diff --git a/pyproject.toml b/pyproject.toml index caf7827..c85337f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,12 +2,11 @@ [project.urls] Repository = "https://github.com/ssweber/pyclickplc" -# Homepage = "https://..." -# Documentation = "https://..." +Documentation = "https://ssweber.github.io/pyclickplc/" [project] name = "pyclickplc" -description = "Utilities for AutomationDirect CLICK Plcs" +description = "Utilities for AutomationDirect CLICK PLCs: Modbus TCP client/server, address helpers, nickname CSV I/O, and DataView CDV I/O." authors = [ { name="ssweber", email="57631333+ssweber@users.noreply.github.com" }, ] @@ -20,9 +19,7 @@ dynamic = ["version"] # Adjust as needed: classifiers = [ # Adjust as needed: - "Development Status :: 1 - Planning", - #"Development Status :: 4 - Beta", - # "Development Status :: 5 - Production/Stable", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", @@ -30,6 +27,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Typing :: Typed", # Include this to avoid accidentally publishing to PyPI: # "Private :: Do Not Upload", @@ -39,6 +37,7 @@ classifiers = [ # ---- Main dependencies ---- dependencies = [ + "pymodbus>=3.8", ] @@ -47,16 +46,23 @@ dependencies = [ [dependency-groups] dev = [ "pytest>=8.3.5", + "pytest-asyncio>=1.0", "ruff>=0.11.0", "codespell>=2.4.1", "rich>=13.9.4", "ty>=0.0.14", "funlog>=0.2.0", ] +docs = [ + "mkdocs>=1.6.1", + "mkdocs-material>=9.6.0", + "mkdocstrings[python]>=0.29.0", + "mkdocs-gen-files>=0.5.0", + "mkdocs-llmstxt>=0.3.0", +] -[project.scripts] -# Add script entry points here: -pyrung = "pyclickplc:main" +# [project.scripts] +# pyrung entry point lives in the pyrung package, not here. # ---- Build system ---- @@ -159,3 +165,4 @@ testpaths = [ ] norecursedirs = [] filterwarnings = [] +asyncio_mode = "auto" diff --git a/src/pyclickplc/__init__.py b/src/pyclickplc/__init__.py index e69de29..813b44d 100644 --- a/src/pyclickplc/__init__.py +++ b/src/pyclickplc/__init__.py @@ -0,0 +1,81 @@ +"""pyclickplc - Utilities for AutomationDirect CLICK PLCs.""" + +from .addresses import ( + AddressRecord, + format_address_display, + normalize_address, + parse_address, +) +from .banks import ( + BANKS, + BankConfig, + DataType, +) +from .client import ClickClient, ModbusResponse +from .dataview import ( + DataViewFile, + DataViewRecord, + check_cdv_file, + get_data_type_for_address, + read_cdv, + validate_new_value, + verify_cdv, + write_cdv, +) +from .modbus import ( + ModbusMapping, + modbus_to_plc, + pack_value, + plc_to_modbus, + unpack_value, +) +from .modbus_service import ConnectionState, ModbusService, ReconnectConfig, WriteResult +from .nicknames import AddressRecordMap, read_csv, write_csv +from .server import ClickServer, MemoryDataProvider, ServerClientInfo +from .server_tui import run_server_tui +from .validation import ( + SYSTEM_NICKNAME_TYPES, + validate_comment, + validate_initial_value, + validate_nickname, +) + +__all__ = [ + "BankConfig", + "BANKS", + "DataType", + "AddressRecord", + "format_address_display", + "parse_address", + "normalize_address", + "ClickClient", + "ModbusResponse", + "ModbusMapping", + "plc_to_modbus", + "modbus_to_plc", + "pack_value", + "unpack_value", + "ModbusService", + "ReconnectConfig", + "ConnectionState", + "WriteResult", + "ClickServer", + "MemoryDataProvider", + "ServerClientInfo", + "run_server_tui", + "DataViewFile", + "DataViewRecord", + "check_cdv_file", + "get_data_type_for_address", + "validate_new_value", + "read_cdv", + "write_cdv", + "verify_cdv", + "read_csv", + "AddressRecordMap", + "write_csv", + "SYSTEM_NICKNAME_TYPES", + "validate_nickname", + "validate_comment", + "validate_initial_value", +] diff --git a/src/pyclickplc/addresses.py b/src/pyclickplc/addresses.py new file mode 100644 index 0000000..7f6e97c --- /dev/null +++ b/src/pyclickplc/addresses.py @@ -0,0 +1,282 @@ +"""Address parsing, formatting, and the AddressRecord data model. + +Provides addr_key calculations, XD/YD helpers, address display formatting, +and the AddressRecord frozen dataclass for representing PLC addresses. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +from .banks import ( + _INDEX_TO_TYPE, + BANKS, + DATA_TYPE_DISPLAY, + DEFAULT_RETENTIVE, + MEMORY_TYPE_BASES, + NON_EDITABLE_TYPES, + DataType, + is_valid_address, +) + +# ============================================================================== +# AddrKey Functions +# ============================================================================== + + +def get_addr_key(memory_type: str, address: int) -> int: + """Calculate AddrKey from memory type and MDB address. + + Args: + memory_type: The memory type (X, Y, C, etc.) + address: The MDB address number + + Returns: + The AddrKey value used as primary key in MDB + + Raises: + KeyError: If memory_type is not recognized + """ + return MEMORY_TYPE_BASES[memory_type] + address + + +def parse_addr_key(addr_key: int) -> tuple[str, int]: + """Parse an AddrKey back to memory type and MDB address. + + Args: + addr_key: The AddrKey value from MDB + + Returns: + Tuple of (memory_type, mdb_address) + + Raises: + KeyError: If the type index is not recognized + """ + type_index = addr_key >> 24 + address = addr_key & 0xFFFFFF + return _INDEX_TO_TYPE[type_index], address + + +# ============================================================================== +# XD/YD Helpers +# ============================================================================== + + +def is_xd_yd_upper_byte(memory_type: str, mdb_address: int) -> bool: + """Check if an XD/YD MDB address is for an upper byte (only XD0u/YD0u at MDB 1).""" + if memory_type in ("XD", "YD"): + return mdb_address == 1 + return False + + +def is_xd_yd_hidden_slot(memory_type: str, mdb_address: int) -> bool: + """Check if an XD/YD MDB address is a hidden slot (odd addresses > 1).""" + if memory_type in ("XD", "YD"): + return mdb_address >= 3 and mdb_address % 2 == 1 + return False + + +def xd_yd_mdb_to_display(mdb_address: int) -> int: + """Convert XD/YD MDB address to display address number. + + MDB 0 -> 0 (XD0), MDB 1 -> 0 (XD0u), MDB 2 -> 1, ..., MDB 16 -> 8. + """ + if mdb_address <= 1: + return 0 + return mdb_address // 2 + + +def xd_yd_display_to_mdb(display_addr: int, upper_byte: bool = False) -> int: + """Convert XD/YD display address to MDB address. + + Args: + display_addr: The display address (0-8) + upper_byte: True for XD0u/YD0u (only valid for display_addr=0) + + Returns: + The MDB address + """ + if display_addr == 0: + return 1 if upper_byte else 0 + return display_addr * 2 + + +# ============================================================================== +# Address Display Functions +# ============================================================================== + + +def format_address_display(memory_type: str, mdb_address: int) -> str: + """Format a memory type and MDB address as a display string. + + X/Y are 3-digit zero-padded. XD/YD use special encoding. Others are unpadded. + """ + if memory_type in ("XD", "YD"): + if mdb_address == 0: + return f"{memory_type}0" + elif mdb_address == 1: + return f"{memory_type}0u" + else: + display_addr = mdb_address // 2 + return f"{memory_type}{display_addr}" + if memory_type in ("X", "Y"): + return f"{memory_type}{mdb_address:03d}" + return f"{memory_type}{mdb_address}" + + +def parse_address(address_str: str) -> tuple[str, int]: + """Parse a display address string to (memory_type, mdb_address). + + Strict: raises ValueError on invalid input. + For XD/YD, returns MDB address: "XD1" -> ("XD", 2), "XD0u" -> ("XD", 1). + + Args: + address_str: Address string like "X001", "XD0", "XD0u", "XD8" + + Returns: + Tuple of (memory_type, mdb_address) + + Raises: + ValueError: If the address string is invalid + """ + if not address_str or not address_str.strip(): + raise ValueError(f"Empty address: {address_str!r}") + + address_str = address_str.strip().upper() + + match = re.match(r"^([A-Z]+)(\d+)(U?)$", address_str) + if not match: + raise ValueError(f"Invalid address format: {address_str!r}") + + memory_type = match.group(1) + display_addr = int(match.group(2)) + is_upper = match.group(3) == "U" + + if memory_type not in MEMORY_TYPE_BASES: + raise ValueError(f"Unknown bank: {memory_type!r}") + + if memory_type in ("XD", "YD"): + if is_upper and display_addr != 0: + raise ValueError(f"Invalid upper byte address: {address_str!r}") + mdb = xd_yd_display_to_mdb(display_addr, is_upper) + if mdb < BANKS[memory_type].min_addr or mdb > BANKS[memory_type].max_addr: + raise ValueError(f"Address out of range: {address_str!r}") + return memory_type, mdb + + if not is_valid_address(memory_type, display_addr): + raise ValueError(f"Address out of range: {address_str!r}") + + return memory_type, display_addr + + +def normalize_address(address: str) -> str | None: + """Normalize an address string to its canonical display form. + + E.g., "x1" -> "X001", "xd0u" -> "XD0u". + + Returns: + The normalized display address, or None if address is invalid. + """ + try: + memory_type, mdb_address = parse_address(address) + except ValueError: + return None + return format_address_display(memory_type, mdb_address) + + +class AddressNormalizerMixin: + """Shared helpers for address normalization and parsing.""" + + @staticmethod + def _normalize_address(address: str) -> str | None: + return normalize_address(address) + + @classmethod + def _normalize_address_strict(cls, address: str) -> str: + normalized = cls._normalize_address(address) + if normalized is None: + raise ValueError(f"Invalid address format: {address!r}") + return normalized + + @classmethod + def _parse_address_strict(cls, address: str) -> tuple[str, int]: + normalized = cls._normalize_address_strict(address) + return parse_address(normalized) + + +# ============================================================================== +# AddressRecord Data Model +# ============================================================================== + + +@dataclass(frozen=True) +class AddressRecord: + """Immutable record for a single PLC address. + + Simpler than ClickNick's AddressRow -- omits all UI validation state. + """ + + # --- Identity --- + memory_type: str # 'X', 'Y', 'C', etc. + address: int # MDB address (1, 2, 3... or 0 for XD/YD) + + # --- Content --- + nickname: str = "" + comment: str = "" + initial_value: str = "" + retentive: bool = False + + # --- Metadata --- + data_type: int = DataType.BIT + used: bool | None = None # None = unknown + + @property + def addr_key(self) -> int: + """Get the AddrKey for this record.""" + return get_addr_key(self.memory_type, self.address) + + @property + def display_address(self) -> str: + """Get the display string for this address.""" + return format_address_display(self.memory_type, self.address) + + @property + def data_type_display(self) -> str: + """Get human-readable data type name.""" + return DATA_TYPE_DISPLAY.get(self.data_type, "") + + @property + def is_default_retentive(self) -> bool: + """Return True if retentive matches the default for this memory_type.""" + default = DEFAULT_RETENTIVE.get(self.memory_type, False) + return self.retentive == default + + @property + def is_default_initial_value(self) -> bool: + """Return True if the initial value is the default for its data type.""" + return ( + self.initial_value == "" + or self.data_type != DataType.TXT + and str(self.initial_value) == "0" + ) + + @property + def has_content(self) -> bool: + """True if record has any user-defined content worth saving.""" + return ( + self.nickname != "" + or self.comment != "" + or not self.is_default_initial_value + or not self.is_default_retentive + ) + + @property + def can_edit_initial_value(self) -> bool: + """True if initial value can be edited for this memory type.""" + return self.memory_type not in NON_EDITABLE_TYPES + + @property + def can_edit_retentive(self) -> bool: + """True if retentive setting can be edited for this memory type.""" + return self.memory_type not in NON_EDITABLE_TYPES diff --git a/src/pyclickplc/banks.py b/src/pyclickplc/banks.py new file mode 100644 index 0000000..637b13f --- /dev/null +++ b/src/pyclickplc/banks.py @@ -0,0 +1,218 @@ +"""PLC memory bank configuration and address validation. + +Defines the 16 memory banks of AutomationDirect CLICK PLCs with their +address ranges, data types, and interleaving relationships. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import IntEnum + +# ============================================================================== +# Data Types +# ============================================================================== + + +class DataType(IntEnum): + """DataType mapping from MDB database.""" + + BIT = 0 # C, CT, SC, T, X, Y - values: "0" or "1" + INT = 1 # DS, SD, TD - 16-bit signed: -32768 to 32767 + INT2 = 2 # CTD, DD - 32-bit signed: -2147483648 to 2147483647 + FLOAT = 3 # DF - float: -3.4028235E+38 to 3.4028235E+38 + HEX = 4 # DH, XD, YD - hex string: "0000" to "FFFF" + TXT = 6 # TXT - single ASCII character + + +# Display names for DataType values +DATA_TYPE_DISPLAY: dict[int, str] = { + DataType.BIT: "BIT", + DataType.INT: "INT", + DataType.INT2: "INT2", + DataType.FLOAT: "FLOAT", + DataType.HEX: "HEX", + DataType.TXT: "TXT", +} + +# Hint text for initial value fields by DataType +DATA_TYPE_HINTS: dict[int, str] = { + DataType.BIT: "0 or 1 (checkbox)", + DataType.INT: "Range: `-32768` to `32767`", + DataType.INT2: "Range: `-2147483648` to `2147483647`", + DataType.FLOAT: "Range: `-3.4028235E+38` to `3.4028235E+38`", + DataType.HEX: "Range: '0000' to 'FFFF'", + DataType.TXT: "Single ASCII char: eg 'A'", +} + + +# ============================================================================== +# Bank Configuration +# ============================================================================== + + +# X/Y sparse address ranges: 10 hardware slots of 16 addresses each +_SPARSE_RANGES: tuple[tuple[int, int], ...] = ( + (1, 16), + (21, 36), + (101, 116), + (201, 216), + (301, 316), + (401, 416), + (501, 516), + (601, 616), + (701, 716), + (801, 816), +) + + +@dataclass(frozen=True) +class BankConfig: + """Configuration for a single PLC memory bank.""" + + name: str + min_addr: int + max_addr: int + data_type: DataType + valid_ranges: tuple[tuple[int, int], ...] | None = None # None = contiguous + interleaved_with: str | None = None + + +BANKS: dict[str, BankConfig] = { + "X": BankConfig("X", 1, 816, DataType.BIT, valid_ranges=_SPARSE_RANGES), + "Y": BankConfig("Y", 1, 816, DataType.BIT, valid_ranges=_SPARSE_RANGES), + "C": BankConfig("C", 1, 2000, DataType.BIT), + "T": BankConfig("T", 1, 500, DataType.BIT, interleaved_with="TD"), + "CT": BankConfig("CT", 1, 250, DataType.BIT, interleaved_with="CTD"), + "SC": BankConfig("SC", 1, 1000, DataType.BIT), + "DS": BankConfig("DS", 1, 4500, DataType.INT), + "DD": BankConfig("DD", 1, 1000, DataType.INT2), + "DH": BankConfig("DH", 1, 500, DataType.HEX), + "DF": BankConfig("DF", 1, 500, DataType.FLOAT), + "XD": BankConfig("XD", 0, 16, DataType.HEX), + "YD": BankConfig("YD", 0, 16, DataType.HEX), + "TD": BankConfig("TD", 1, 500, DataType.INT, interleaved_with="T"), + "CTD": BankConfig("CTD", 1, 250, DataType.INT2, interleaved_with="CT"), + "SD": BankConfig("SD", 1, 1000, DataType.INT), + "TXT": BankConfig("TXT", 1, 1000, DataType.TXT), +} + + +# ============================================================================== +# Derived Constants +# ============================================================================== + + +# DataType by memory type (derived from BANKS) +MEMORY_TYPE_TO_DATA_TYPE: dict[str, int] = {name: b.data_type for name, b in BANKS.items()} + +# Types that only hold bit values +BIT_ONLY_TYPES: frozenset[str] = frozenset( + name for name, b in BANKS.items() if b.data_type == DataType.BIT +) + + +# ============================================================================== +# Legacy Dicts (for addr_key system and ClickNick compatibility) +# ============================================================================== + + +# Base values for AddrKey calculation (Primary Key in MDB) +MEMORY_TYPE_BASES: dict[str, int] = { + "X": 0x0000000, + "Y": 0x1000000, + "C": 0x2000000, + "T": 0x3000000, + "CT": 0x4000000, + "SC": 0x5000000, + "DS": 0x6000000, + "DD": 0x7000000, + "DH": 0x8000000, + "DF": 0x9000000, + "XD": 0xA000000, + "YD": 0xB000000, + "TD": 0xC000000, + "CTD": 0xD000000, + "SD": 0xE000000, + "TXT": 0xF000000, +} + +# Reverse mapping: type_index -> memory_type +_INDEX_TO_TYPE: dict[int, str] = {v >> 24: k for k, v in MEMORY_TYPE_BASES.items()} + +# Default retentive values by memory type (from CLICK documentation) +DEFAULT_RETENTIVE: dict[str, bool] = { + "X": False, + "Y": False, + "C": False, + "T": False, + "CT": True, # Counters are retentive by default + "SC": False, # Can't change + "DS": True, # Data registers are retentive by default + "DD": True, + "DH": True, + "DF": True, + "XD": False, # Can't change + "YD": False, # Can't change + "TD": False, # See note + "CTD": True, # See note + "SD": False, # Can't change + "TXT": True, +} + +# Canonical set of interleaved type pairs +INTERLEAVED_TYPE_PAIRS: frozenset[frozenset[str]] = frozenset( + { + frozenset({"T", "TD"}), + frozenset({"CT", "CTD"}), + } +) + +# Bidirectional lookup for interleaved pairs +INTERLEAVED_PAIRS: dict[str, str] = { + "T": "TD", + "TD": "T", + "CT": "CTD", + "CTD": "CT", +} + +# Memory types that share retentive with their paired type +PAIRED_RETENTIVE_TYPES: dict[str, str] = {"TD": "T", "CTD": "CT"} + +# Memory types where InitialValue/Retentive cannot be edited +NON_EDITABLE_TYPES: frozenset[str] = frozenset({"SC", "SD", "XD", "YD"}) + + +# ============================================================================== +# Address Validation +# ============================================================================== + + +def is_valid_address(bank_name: str, address: int) -> bool: + """Check if an address is valid for the given bank. + + Uses valid_ranges for sparse banks (X/Y), simple min/max for contiguous banks. + + Args: + bank_name: The bank name (e.g., "X", "DS") + address: The address number to validate + + Returns: + True if the address is valid for the bank + """ + bank = BANKS.get(bank_name) + if bank is None: + return False + + if bank.valid_ranges is not None: + return any(lo <= address <= hi for lo, hi in bank.valid_ranges) + + return bank.min_addr <= address <= bank.max_addr + + +# ============================================================================== +# Runtime Assertions +# ============================================================================== + +assert set(BANKS) == set(MEMORY_TYPE_BASES), "BANKS and MEMORY_TYPE_BASES keys must match" +assert set(BANKS) == set(DEFAULT_RETENTIVE), "BANKS and DEFAULT_RETENTIVE keys must match" diff --git a/src/pyclickplc/blocks.py b/src/pyclickplc/blocks.py new file mode 100644 index 0000000..2dff7e3 --- /dev/null +++ b/src/pyclickplc/blocks.py @@ -0,0 +1,596 @@ +"""Block tag parsing, matching, and range computation. + +Provides BlockTag/BlockRange dataclasses, parsing functions, and multi-row +block operations for CLICK PLC address editors. +""" + +from __future__ import annotations + +import re +from collections.abc import Iterable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal, Protocol + +if TYPE_CHECKING: + pass + + +class HasComment(Protocol): + """Protocol for objects that have a comment and optional memory_type attribute.""" + + comment: str + memory_type: str | None + + +@dataclass +class BlockRange: + """A matched block range with start/end indices and metadata. + + Represents a complete block from opening to closing tag (or self-closing). + """ + + start_idx: int + end_idx: int # Same as start_idx for self-closing tags + name: str + bg_color: str | None + memory_type: str | None = None # Memory type for filtering in interleaved views + + +@dataclass +class BlockTag: + """Result of parsing a block tag from a comment. + + Block tags mark sections in the Address Editor: + - - opening tag for a range + - - closing tag for a range + - - self-closing tag for a singular point + - - opening tag with background color + """ + + name: str | None + tag_type: Literal["open", "close", "self-closing"] | None + remaining_text: str + bg_color: str | None + + +@dataclass(frozen=True) +class StructuredBlockName: + """Opt-in parse result for structured block naming conventions. + + `parse_block_tag()` treats block names as opaque strings. This helper is + intentionally separate and optional for callers that want to interpret + naming conventions such as: + - UDT fields: ``Base.field`` + - Named arrays: ``Base:named_array(count,stride)`` + - Plain blocks with logical start override: + ``Base:block(n)`` / ``Base:block(start=n)`` + """ + + raw: str + kind: Literal["plain", "udt", "named_array", "block"] + base: str + field: str | None = None + count: int | None = None + stride: int | None = None + start: int | None = None + + +_STRUCTURED_IDENT = r"[A-Za-z_][A-Za-z0-9_]*" +_UDT_BLOCK_NAME_RE = re.compile(rf"^(?P{_STRUCTURED_IDENT})\.(?P{_STRUCTURED_IDENT})$") +_NAMED_ARRAY_BLOCK_NAME_RE = re.compile( + rf"^(?P{_STRUCTURED_IDENT}):named_array\((?P[1-9][0-9]*),(?P[1-9][0-9]*)\)$" +) +_BLOCK_START_BLOCK_NAME_RE = re.compile( + rf"^(?P{_STRUCTURED_IDENT}):block\((?P(?:0|[1-9][0-9]*|start=(?:0|[1-9][0-9]*)))\)$" +) + + +def parse_structured_block_name(name: str) -> StructuredBlockName: + """Parse optional structured naming conventions from a block name. + + This parser is non-throwing and non-strict by design. Names that do not + exactly match a supported convention are returned as ``kind="plain"``. + """ + named_array_match = _NAMED_ARRAY_BLOCK_NAME_RE.fullmatch(name) + if named_array_match is not None: + return StructuredBlockName( + raw=name, + kind="named_array", + base=named_array_match.group("base"), + count=int(named_array_match.group("count")), + stride=int(named_array_match.group("stride")), + ) + + udt_match = _UDT_BLOCK_NAME_RE.fullmatch(name) + if udt_match is not None: + return StructuredBlockName( + raw=name, + kind="udt", + base=udt_match.group("base"), + field=udt_match.group("field"), + ) + + block_start_match = _BLOCK_START_BLOCK_NAME_RE.fullmatch(name) + if block_start_match is not None: + start_token = block_start_match.group("start") + if start_token.startswith("start="): + start = int(start_token.split("=", maxsplit=1)[1]) + else: + start = int(start_token) + return StructuredBlockName( + raw=name, + kind="block", + base=block_start_match.group("base"), + start=start, + ) + + return StructuredBlockName(raw=name, kind="plain", base=name) + + +def group_udt_block_names(names: Iterable[str]) -> dict[str, tuple[str, ...]]: + """Group UDT field block names by base name. + + Returns: + Mapping of ``Base`` -> ordered tuple of unique field names for tags + that match ``Base.field``. + """ + grouped: dict[str, list[str]] = {} + for name in names: + structured = parse_structured_block_name(name) + if structured.kind != "udt" or structured.field is None: + continue + fields = grouped.setdefault(structured.base, []) + if structured.field not in fields: + fields.append(structured.field) + + return {base: tuple(fields) for base, fields in grouped.items()} + + +def _extract_bg_attribute(tag_content: str) -> tuple[str, str | None]: + """Extract bg attribute from tag content. + + Args: + tag_content: The content between < and > (e.g., 'Name bg="#FFCDD2"') + + Returns: + Tuple of (name_part, bg_color) + - name_part: Tag content with bg attribute removed + - bg_color: The color value, or None if not present + """ + import re + + # Look for bg="..." or bg='...' + match = re.search(r'\s+bg=["\']([^"\']+)["\']', tag_content) + if match: + bg_color = match.group(1) + # Remove the bg attribute from the tag content + name_part = tag_content[: match.start()] + tag_content[match.end() :] + return name_part.strip(), bg_color + return tag_content, None + + +def _is_valid_tag_name(name: str) -> bool: + """Check if a tag name is valid (not a mathematical expression). + + Valid names must contain at least one letter. This prevents expressions + like '< 5 >' or '< 10 >' from being parsed as tags. + + Args: + name: The potential tag name to check + + Returns: + True if the name contains at least one letter + """ + return any(c.isalpha() for c in name) + + +def _try_parse_tag_at(comment: str, start_pos: int) -> BlockTag | None: + """Try to parse a block tag starting at the given position. + + Args: + comment: The full comment string + start_pos: Position of the '<' character + + Returns: + BlockTag if valid tag found, None otherwise + """ + end = comment.find(">", start_pos) + if end == -1: + return None + + tag_content = comment[start_pos + 1 : end] # content between < and > + + # Empty tag <> is invalid + if not tag_content or not tag_content.strip(): + return None + + # Calculate remaining text (text before + text after the tag) + text_before = comment[:start_pos] + text_after = comment[end + 1 :] + remaining = text_before + text_after + + # Self-closing: or + if tag_content.rstrip().endswith("/"): + content_without_slash = tag_content.rstrip()[:-1].strip() + name_part, bg_color = _extract_bg_attribute(content_without_slash) + name = name_part.strip() + if name and _is_valid_tag_name(name): + return BlockTag(name, "self-closing", remaining, bg_color) + return None + + # Closing: (no bg attribute on closing tags) + if tag_content.startswith("/"): + name = tag_content[1:].strip() + if name and _is_valid_tag_name(name): + return BlockTag(name, "close", remaining, None) + return None + + # Opening: or + name_part, bg_color = _extract_bg_attribute(tag_content) + name = name_part.strip() + if name and _is_valid_tag_name(name): + return BlockTag(name, "open", remaining, bg_color) + + return None + + +def parse_block_tag(comment: str) -> BlockTag: + """Parse block tag from anywhere in a comment. + + Block tags mark sections in the Address Editor: + - - opening tag for a range (can have text before/after) + - - closing tag for a range (can have text before/after) + - - self-closing tag for a singular point + - - opening tag with background color + - - self-closing tag with background color + + The function searches for tags anywhere in the comment, not just at the start. + Mathematical expressions like '< 5 >' are not parsed as tags. + + Args: + comment: The comment string to parse + + Returns: + BlockTag with name, tag_type, remaining_text, and bg_color + """ + if not comment: + return BlockTag(None, None, "", None) + + # Search for '<' anywhere in the comment + pos = 0 + while True: + start_pos = comment.find("<", pos) + if start_pos == -1: + break + + result = _try_parse_tag_at(comment, start_pos) + if result is not None: + return result + + # Try next '<' character + pos = start_pos + 1 + + return BlockTag(None, None, comment, None) + + +def get_block_type(comment: str) -> str | None: + """Determine the type of block tag in a comment. + + Args: + comment: The comment string to check + + Returns: + 'open' for , 'close' for , + 'self-closing' for , or None if not a block tag + """ + return parse_block_tag(comment).tag_type + + +def is_block_tag(comment: str) -> bool: + """Check if a comment starts with a block tag (any type). + + Block tags mark sections in the Address Editor: + - - opening tag for a range + - - closing tag for a range + - - self-closing tag for a singular point + + Args: + comment: The comment string to check + + Returns: + True if the comment starts with any type of block tag + """ + return get_block_type(comment) is not None + + +def extract_block_name(comment: str) -> str | None: + """Extract block name from a comment that starts with a block tag. + + Args: + comment: The comment string (e.g., "Valve info", "", "") + + Returns: + The block name (e.g., "Motor", "Spare"), or None if no tag + """ + return parse_block_tag(comment).name + + +def strip_block_tag(comment: str) -> str: + """Strip block tag from a comment, returning any text after the tag. + + Args: + comment: The comment string (e.g., "Valve info") + + Returns: + Text after the tag (e.g., "Valve info"), or original if no tag + """ + if not comment: + return "" + block_tag = parse_block_tag(comment) + if block_tag.tag_type is not None: + return block_tag.remaining_text + return comment + + +def format_block_tag( + name: str, + tag_type: Literal["open", "close", "self-closing"], + bg_color: str | None = None, +) -> str: + """Format a block tag string from its components. + + Args: + name: The block name (e.g., "Motor", "Alarms") + tag_type: "open", "close", or "self-closing" + bg_color: Optional background color (e.g., "#FFCDD2", "Red") + + Returns: + Formatted block tag string: + - open: "" or "" + - close: "" + - self-closing: "" or "" + """ + bg_attr = f' bg="{bg_color}"' if bg_color else "" + + if tag_type == "self-closing": + return f"<{name}{bg_attr} />" + elif tag_type == "close": + return f"" + else: # open + return f"<{name}{bg_attr}>" + + +# ============================================================================= +# Multi-Row Block Operations +# ============================================================================= + + +def get_all_block_names(rows: list[HasComment]) -> set[str]: + """Get all unique block names from a list of rows. + + Scans all comments for block tags and extracts their names. + Used for duplicate name validation. + + Args: + rows: List of objects with .comment attribute + + Returns: + Set of unique block names (case-sensitive) + """ + names: set[str] = set() + for row in rows: + tag = parse_block_tag(row.comment) + if tag.name: + names.add(tag.name) + return names + + +def is_block_name_available( + name: str, + rows: list[HasComment], + exclude_addr_keys: set[int] | None = None, +) -> bool: + """Check if a block name is available (not already in use). + + Args: + name: The block name to check + rows: List of objects with .comment and .addr_key attributes + exclude_addr_keys: Optional set of addr_keys to exclude from check + (used when renaming a block - excludes the block being renamed) + + Returns: + True if the name is available, False if already in use + """ + exclude = exclude_addr_keys or set() + for row in rows: + if hasattr(row, "addr_key") and row.addr_key in exclude: + continue + tag = parse_block_tag(row.comment) + if tag.name == name: + return False + return True + + +def find_paired_tag_index( + rows: list[HasComment], row_idx: int, tag: BlockTag | None = None +) -> int | None: + """Find the row index of the paired open/close block tag. + + Uses nesting depth to correctly match tags when there are multiple + blocks with the same name (nested or separate sections). + + Only matches tags within the same memory_type (if available) to correctly + handle interleaved views like T/TD where each type has its own tags. + + Args: + rows: List of objects with .comment and optional .memory_type attributes + row_idx: Index of the row containing the tag + tag: Parsed BlockTag, or None to parse from rows[row_idx].comment + + Returns: + Row index of the paired tag, or None if not found + """ + if tag is None: + tag = parse_block_tag(rows[row_idx].comment) + + if not tag.name or tag.tag_type == "self-closing": + return None + + # Get memory type of source row (if available) for filtering + source_type = getattr(rows[row_idx], "memory_type", None) + + if tag.tag_type == "open": + # Search forward for matching close tag, respecting nesting + depth = 1 + for i in range(row_idx + 1, len(rows)): + # Skip rows with different memory type + if source_type and getattr(rows[i], "memory_type", None) != source_type: + continue + other_tag = parse_block_tag(rows[i].comment) + if other_tag.name == tag.name: + if other_tag.tag_type == "open": + depth += 1 + elif other_tag.tag_type == "close": + depth -= 1 + if depth == 0: + return i + elif tag.tag_type == "close": + # Search backward for matching open tag, respecting nesting + depth = 1 + for i in range(row_idx - 1, -1, -1): + # Skip rows with different memory type + if source_type and getattr(rows[i], "memory_type", None) != source_type: + continue + other_tag = parse_block_tag(rows[i].comment) + if other_tag.name == tag.name: + if other_tag.tag_type == "close": + depth += 1 + elif other_tag.tag_type == "open": + depth -= 1 + if depth == 0: + return i + return None + + +def find_block_range_indices( + rows: list[HasComment], row_idx: int, tag: BlockTag | None = None +) -> tuple[int, int] | None: + """Find the (start_idx, end_idx) range for a block tag. + + Uses nesting depth to correctly match tags when there are multiple + blocks with the same name. + + Args: + rows: List of objects with a .comment attribute + row_idx: Index of the row containing the tag + tag: Parsed BlockTag, or None to parse from rows[row_idx].comment + + Returns: + Tuple of (start_idx, end_idx) inclusive, or None if tag is invalid + """ + if tag is None: + tag = parse_block_tag(rows[row_idx].comment) + + if not tag.name or not tag.tag_type: + return None + + if tag.tag_type == "self-closing": + return (row_idx, row_idx) + + if tag.tag_type == "open": + paired_idx = find_paired_tag_index(rows, row_idx, tag) + if paired_idx is not None: + return (row_idx, paired_idx) + # No close found - just the opening row + return (row_idx, row_idx) + + if tag.tag_type == "close": + paired_idx = find_paired_tag_index(rows, row_idx, tag) + if paired_idx is not None: + return (paired_idx, row_idx) + # No open found - just the closing row + return (row_idx, row_idx) + + return None + + +def compute_all_block_ranges(rows: list[HasComment]) -> list[BlockRange]: + """Compute all block ranges from a list of rows using stack-based matching. + + Correctly handles nested blocks and multiple blocks with the same name. + Only matches open/close tags within the same memory_type to handle + interleaved views like T/TD correctly. + + Args: + rows: List of objects with .comment and optional .memory_type attributes + + Returns: + List of BlockRange objects, sorted by start_idx + """ + ranges: list[BlockRange] = [] + + # Stack for tracking open tags: (memory_type, name) -> [(start_idx, bg_color), ...] + # Using (memory_type, name) as key ensures T's and TD's are separate + open_tags: dict[tuple[str | None, str], list[tuple[int, str | None]]] = {} + + for row_idx, row in enumerate(rows): + tag = parse_block_tag(row.comment) + if not tag.name: + continue + + memory_type = getattr(row, "memory_type", None) + stack_key = (memory_type, tag.name) + + if tag.tag_type == "self-closing": + ranges.append(BlockRange(row_idx, row_idx, tag.name, tag.bg_color, memory_type)) + elif tag.tag_type == "open": + if stack_key not in open_tags: + open_tags[stack_key] = [] + open_tags[stack_key].append((row_idx, tag.bg_color)) + elif tag.tag_type == "close": + if stack_key in open_tags and open_tags[stack_key]: + start_idx, bg_color = open_tags[stack_key].pop() + ranges.append(BlockRange(start_idx, row_idx, tag.name, bg_color, memory_type)) + + # Handle unclosed tags as singular points + for (mem_type, name), stack in open_tags.items(): + for start_idx, bg_color in stack: + ranges.append(BlockRange(start_idx, start_idx, name, bg_color, mem_type)) + + # Sort by start index + ranges.sort(key=lambda r: r.start_idx) + return ranges + + +def validate_block_span(rows: list) -> tuple[bool, str | None]: + """Validate that a block span doesn't cross memory type boundaries. + + Blocks should only contain addresses of the same memory type, + with the exception of paired types (T+TD, CT+CTD) which are + interleaved and can share blocks. + + Args: + rows: List of objects with .memory_type attribute + + Returns: + Tuple of (is_valid, error_message). + - (True, None) if all rows have compatible memory types + - (False, error_message) if rows span incompatible memory types + """ + from .banks import INTERLEAVED_TYPE_PAIRS + + if not rows: + return True, None + + # Get unique memory types in the selection + memory_types = {row.memory_type for row in rows} + + if len(memory_types) == 1: + return True, None + + # Check if it's a valid paired type combination + if frozenset(memory_types) in INTERLEAVED_TYPE_PAIRS: + return True, None + + types_str = ", ".join(sorted(memory_types)) + return False, f"Blocks cannot span multiple memory types ({types_str})" diff --git a/src/pyclickplc/client.py b/src/pyclickplc/client.py new file mode 100644 index 0000000..8abbe94 --- /dev/null +++ b/src/pyclickplc/client.py @@ -0,0 +1,895 @@ +"""Async Modbus TCP client driver for AutomationDirect CLICK PLCs. + +Provides ClickClient with bank accessors, address interface, and tag interface. +""" + +from __future__ import annotations + +from collections.abc import Coroutine, Iterator, Mapping +from typing import Any, ClassVar, Generic, Literal, TypeAlias, TypeVar, cast, overload + +from pymodbus.client import AsyncModbusTcpClient + +from .addresses import ( + AddressNormalizerMixin, + AddressRecord, + format_address_display, + parse_address, + xd_yd_display_to_mdb, + xd_yd_mdb_to_display, +) +from .banks import BANKS +from .modbus import ( + MODBUS_MAPPINGS, + pack_value, + plc_to_modbus, + unpack_value, +) +from .nicknames import DATA_TYPE_CODE_TO_STR +from .validation import assert_runtime_value + +PlcValue = bool | int | float | str +TValue_co = TypeVar("TValue_co", bound=PlcValue, covariant=True) + +BoolBankName: TypeAlias = Literal["X", "Y", "C", "T", "CT", "SC"] +IntBankName: TypeAlias = Literal["DS", "DD", "DH", "TD", "CTD", "SD", "XD", "YD"] +FloatBankName: TypeAlias = Literal["DF"] +StrBankName: TypeAlias = Literal["TXT"] + +BoolBankAttr: TypeAlias = Literal["x", "X", "y", "Y", "c", "C", "t", "T", "ct", "CT", "sc", "SC"] +DisplayBankAttr: TypeAlias = Literal["xd", "XD", "yd", "YD"] +UpperByteAttr: TypeAlias = Literal["xd0u", "XD0U", "yd0u", "YD0U"] +IntBankAttr: TypeAlias = Literal[ + "ds", + "DS", + "dd", + "DD", + "dh", + "DH", + "td", + "TD", + "ctd", + "CTD", + "sd", + "SD", +] +FloatBankAttr: TypeAlias = Literal["df", "DF"] +StrBankAttr: TypeAlias = Literal["txt", "TXT"] + +# ============================================================================== +# ModbusResponse +# ============================================================================== + + +class ModbusResponse(AddressNormalizerMixin, Mapping[str, TValue_co], Generic[TValue_co]): + """Immutable mapping with normalized PLC address keys. + + Keys are stored in canonical uppercase form (``DS1``, ``X001``). + Look-ups normalise the key automatically, so ``response["ds1"]`` + and ``response["DS1"]`` both work. + """ + + __slots__ = ("_data",) + + def __init__(self, data: dict[str, TValue_co]) -> None: + self._data = data + + # -- Mapping interface -------------------------------------------------- + + def __getitem__(self, key: str) -> TValue_co: + normalized = self._normalize_address(key) + if normalized is not None and normalized in self._data: + return self._data[normalized] + raise KeyError(key) + + def __contains__(self, key: object) -> bool: + if not isinstance(key, str): + return False + normalized = self._normalize_address(key) + return normalized is not None and normalized in self._data + + def __iter__(self) -> Iterator[str]: + return iter(self._data) + + def __len__(self) -> int: + return len(self._data) + + # -- Comparison --------------------------------------------------------- + + def __eq__(self, other: object) -> bool: + if isinstance(other, ModbusResponse): + return self._data == other._data + if isinstance(other, dict): + if len(other) != len(self._data): + return False + normalized: dict[str, object] = {} + for k, v in other.items(): + nk = self._normalize_address(k) if isinstance(k, str) else None + if nk is None: + return False + normalized[nk] = v + return self._data == normalized + return NotImplemented + + def __repr__(self) -> str: + return f"ModbusResponse({self._data!r})" + + +# ============================================================================== +# Constants +# ============================================================================== + +# Map bank name -> data type string for validation +_DATA_TYPE_STR: dict[str, str] = { + "X": "bool", + "Y": "bool", + "C": "bool", + "T": "bool", + "CT": "bool", + "SC": "bool", + "DS": "int16", + "DD": "int32", + "DH": "hex", + "DF": "float", + "TXT": "str", + "TD": "int16", + "CTD": "int32", + "SD": "int16", + "XD": "hex", + "YD": "hex", +} + +# ============================================================================== +# Tag loading +# ============================================================================== + + +def _build_tags_from_records(records: Mapping[str, AddressRecord]) -> dict[str, dict[str, str]]: + """Build ClickClient tag definitions from AddressRecords.""" + tags: dict[str, dict[str, str]] = {} + seen: dict[str, tuple[str, str]] = {} + for record in records.values(): + nickname = record.nickname.strip() + if nickname == "": + continue + key = nickname.lower() + if key in seen: + first_name, first_address = seen[key] + raise ValueError( + "Case-insensitive duplicate nickname in ClickClient tags: " + f"{first_name!r}@{first_address} conflicts with " + f"{nickname!r}@{record.display_address}" + ) + seen[key] = (nickname, record.display_address) + tags[nickname] = { + "address": record.display_address, + "type": DATA_TYPE_CODE_TO_STR.get(record.data_type, ""), + "comment": record.comment, + } + # Sort by address + return dict(sorted(tags.items(), key=lambda item: item[1]["address"])) + + +# ============================================================================== +# AddressAccessor +# ============================================================================== + + +def _format_bank_address(bank: str, index: int) -> str: + """Format address string for return dicts.""" + return format_address_display(bank, index) + + +class AddressAccessor(Generic[TValue_co]): + """Provides read/write access to a specific PLC memory bank.""" + + def __init__(self, plc: ClickClient, bank: str) -> None: + self._plc = plc + self._bank = bank + self._mapping = MODBUS_MAPPINGS[bank] + self._bank_cfg = BANKS[bank] + + async def read(self, start: int, end: int | None = None) -> ModbusResponse[TValue_co]: + """Read single value or range (inclusive). + + Always returns a ModbusResponse, even for a single address. + Use ``await plc.ds[1]`` for a bare value. + """ + bank = self._bank + + if end is None: + # Single value → wrap in ModbusResponse + value = await self._read_single(start) + key = _format_bank_address(bank, start) + return ModbusResponse({key: value}) + + if end <= start: + raise ValueError("End address must be greater than start address.") + + # Range read + if bank == "TXT": + return await self._read_txt_range(start, end) + if self._bank_cfg.valid_ranges is not None: + return await self._read_sparse_range(start, end) + return await self._read_range(start, end) + + async def _read_single(self, index: int) -> TValue_co: + """Read a single PLC address.""" + bank = self._bank + self._validate_index(index) + + if bank == "TXT": + return cast(TValue_co, await self._read_txt(index)) + + if self._mapping.is_coil: + addr, _ = plc_to_modbus(bank, index) + result = await self._plc._read_coils(addr, 1, bank) + return cast(TValue_co, result[0]) + + addr, count = plc_to_modbus(bank, index) + regs = await self._plc._read_registers(addr, count, bank) + return cast(TValue_co, unpack_value(regs, self._bank_cfg.data_type)) + + async def _read_range(self, start: int, end: int) -> ModbusResponse[TValue_co]: + """Read a contiguous range.""" + bank = self._bank + self._validate_index(start) + self._validate_index(end) + result: dict[str, TValue_co] = {} + + if self._mapping.is_coil: + addr_start, _ = plc_to_modbus(bank, start) + addr_end, _ = plc_to_modbus(bank, end) + count = addr_end - addr_start + 1 + bits = await self._plc._read_coils(addr_start, count, bank) + for i, idx in enumerate(range(start, end + 1)): + key = _format_bank_address(bank, idx) + result[key] = cast(TValue_co, bits[i]) + else: + addr_start, _ = plc_to_modbus(bank, start) + addr_end, count_last = plc_to_modbus(bank, end) + total_regs = (addr_end + count_last) - addr_start + regs = await self._plc._read_registers(addr_start, total_regs, bank) + width = self._mapping.width + data_type = self._bank_cfg.data_type + for i, idx in enumerate(range(start, end + 1)): + offset = i * width + val = unpack_value(regs[offset : offset + width], data_type) + key = _format_bank_address(bank, idx) + result[key] = cast(TValue_co, val) + + return ModbusResponse(result) + + async def _read_sparse_range(self, start: int, end: int) -> ModbusResponse[TValue_co]: + """Read a sparse (X/Y) range, skipping gaps.""" + bank = self._bank + # Enumerate valid addresses in [start, end] + valid_addrs: list[int] = [] + ranges = self._bank_cfg.valid_ranges + assert ranges is not None + for lo, hi in ranges: + for a in range(max(lo, start), min(hi, end) + 1): + valid_addrs.append(a) + + if not valid_addrs: + raise ValueError(f"No valid {bank} addresses in range {start}-{end}") + + # Read the full coil span + addr_first, _ = plc_to_modbus(bank, valid_addrs[0]) + addr_last, _ = plc_to_modbus(bank, valid_addrs[-1]) + count = addr_last - addr_first + 1 + bits = await self._plc._read_coils(addr_first, count, bank) + + result: dict[str, TValue_co] = {} + for a in valid_addrs: + addr, _ = plc_to_modbus(bank, a) + bit_idx = addr - addr_first + key = _format_bank_address(bank, a) + result[key] = cast(TValue_co, bits[bit_idx]) + + return ModbusResponse(result) + + async def _read_txt(self, index: int) -> str: + """Read a single TXT address.""" + # TXT packs 2 chars per register: odd index = low byte, even = high byte + reg_index = (index - 1) // 2 + reg_addr = MODBUS_MAPPINGS["TXT"].base + reg_index + regs = await self._plc._read_registers(reg_addr, 1, "TXT") + reg_val = regs[0] + if index % 2 == 1: + # Odd: low byte + return chr(reg_val & 0xFF) + else: + # Even: high byte + return chr((reg_val >> 8) & 0xFF) + + async def _read_txt_range(self, start: int, end: int) -> ModbusResponse[TValue_co]: + """Read a range of TXT addresses.""" + self._validate_index(start) + self._validate_index(end) + # Compute register range + first_reg = (start - 1) // 2 + last_reg = (end - 1) // 2 + reg_base = MODBUS_MAPPINGS["TXT"].base + regs = await self._plc._read_registers( + reg_base + first_reg, last_reg - first_reg + 1, "TXT" + ) + + result: dict[str, TValue_co] = {} + for idx in range(start, end + 1): + reg_offset = (idx - 1) // 2 - first_reg + reg_val = regs[reg_offset] + if idx % 2 == 1: + ch = chr(reg_val & 0xFF) + else: + ch = chr((reg_val >> 8) & 0xFF) + result[_format_bank_address("TXT", idx)] = cast(TValue_co, ch) + + return ModbusResponse(result) + + async def write( + self, + start: int, + data: bool | int | float | str | list[bool] | list[int] | list[float] | list[str], + ) -> None: + """Write single value or list of consecutive values.""" + if isinstance(data, list): + await self._write_list(start, data) + else: + await self._write_single(start, data) + + async def _write_single(self, index: int, value: bool | int | float | str) -> None: + """Write a single value.""" + bank = self._bank + self._validate_index(index) + self._validate_writable(index) + self._validate_value(index, value) + + if bank == "TXT": + await self._write_txt(index, str(value)) + return + + if self._mapping.is_coil: + addr, _ = plc_to_modbus(bank, index) + await self._plc._write_coils(addr, [bool(value)]) + return + + addr, count = plc_to_modbus(bank, index) + regs = pack_value(value, self._bank_cfg.data_type) + await self._plc._write_registers(addr, regs) + + async def _write_list(self, start: int, values: list) -> None: + """Write a list of consecutive values.""" + bank = self._bank + + if bank == "TXT": + # Write TXT as string chars + for i, v in enumerate(values): + idx = start + i + self._validate_index(idx) + self._validate_writable(idx) + self._validate_value(idx, v) + await self._write_txt(idx, str(v)) + return + + # Validate all addresses and values first + for i, v in enumerate(values): + idx = start + i + self._validate_index(idx) + self._validate_writable(idx) + self._validate_value(idx, v) + + if self._mapping.is_coil: + if self._bank_cfg.valid_ranges is not None: + await self._write_sparse_coils(start, values) + else: + addr, _ = plc_to_modbus(bank, start) + await self._plc._write_coils(addr, [bool(v) for v in values]) + else: + addr, _ = plc_to_modbus(bank, start) + data_type = self._bank_cfg.data_type + all_regs: list[int] = [] + for v in values: + all_regs.extend(pack_value(v, data_type)) + await self._plc._write_registers(addr, all_regs) + + async def _write_sparse_coils(self, start: int, values: list) -> None: + """Write coils over a sparse (X/Y) range, padding gaps with False.""" + bank = self._bank + ranges = self._bank_cfg.valid_ranges + assert ranges is not None + + # Build list of valid addresses + end_index = start + len(values) - 1 + valid_addrs: list[int] = [] + for lo, hi in ranges: + for a in range(max(lo, start), min(hi, end_index) + 1): + valid_addrs.append(a) + + if not valid_addrs: + return + + # Map values to valid addresses + value_map = dict(zip(valid_addrs, [bool(v) for v in values], strict=False)) + + addr_first, _ = plc_to_modbus(bank, valid_addrs[0]) + addr_last, _ = plc_to_modbus(bank, valid_addrs[-1]) + total_coils = addr_last - addr_first + 1 + + # Build coil data with False padding for gaps + coil_data: list[bool] = [] + for i in range(total_coils): + coil_addr = addr_first + i + from .modbus import modbus_to_plc + + mapped = modbus_to_plc(coil_addr, is_coil=True) + if mapped is not None and mapped[1] in value_map: + coil_data.append(value_map[mapped[1]]) + else: + coil_data.append(False) + + await self._plc._write_coils(addr_first, coil_data) + + async def _write_txt(self, index: int, char: str) -> None: + """Write a single TXT character, preserving the twin byte.""" + reg_index = (index - 1) // 2 + reg_addr = MODBUS_MAPPINGS["TXT"].base + reg_index + + # Read current register to preserve the other byte + regs = await self._plc._read_registers(reg_addr, 1, "TXT") + reg_val = regs[0] + byte_val = ord(char) & 0xFF if char else 0 + + if index % 2 == 1: + # Odd: low byte + new_val = (reg_val & 0xFF00) | byte_val + else: + # Even: high byte + new_val = (reg_val & 0x00FF) | (byte_val << 8) + + await self._plc._write_registers(reg_addr, [new_val]) + + def _validate_index(self, index: int) -> None: + """Validate address index for this bank.""" + bank = self._bank + cfg = self._bank_cfg + if cfg.valid_ranges is not None: + if not any(lo <= index <= hi for lo, hi in cfg.valid_ranges): + raise ValueError(f"{bank} address must be *01-*16.") + else: + if index < cfg.min_addr or index > cfg.max_addr: + raise ValueError(f"{bank} must be in [{cfg.min_addr}, {cfg.max_addr}]") + + def _validate_writable(self, index: int) -> None: + """Validate that the address is writable.""" + mapping = self._mapping + if mapping.writable is not None: + # Bank has a specific writable subset (SC, SD) + if index not in mapping.writable: + raise ValueError(f"{self._bank}{index} is not writable.") + elif not mapping.is_writable: + raise ValueError(f"{self._bank}{index} is not writable.") + + def _validate_value(self, index: int, value: bool | int | float | str) -> None: + """Validate runtime write value for this bank and index.""" + assert_runtime_value(self._bank_cfg.data_type, value, bank=self._bank, index=index) + + def __repr__(self) -> str: + return f"" + + def __getitem__(self, key: int) -> Coroutine[Any, Any, TValue_co]: + """Enable ``await plc.ds[1]`` syntax for single-value reads.""" + if isinstance(key, slice): + raise TypeError("Slicing is not supported. Use read(start, end) for range reads.") + return self._read_single(key) + + +class DisplayAddressAccessor(Generic[TValue_co]): + """Display-indexed accessor for XD/YD banks. + + Uses display index range ``0..8`` and excludes hidden odd MDB slots. + """ + + def __init__(self, plc: ClickClient, bank: str) -> None: + if bank not in {"XD", "YD"}: + raise ValueError(f"DisplayAddressAccessor supports XD/YD only, got {bank!r}") + self._plc = plc + self._bank = bank + self._raw = cast(AddressAccessor[TValue_co], plc._get_accessor(bank)) + + def _validate_display_index(self, index: int) -> None: + if index < 0 or index > 8: + raise ValueError(f"{self._bank} must be in [0, 8]") + + @staticmethod + def _display_to_mdb(index: int) -> int: + return xd_yd_display_to_mdb(index, upper_byte=False) + + async def _read_single_display(self, index: int) -> TValue_co: + self._validate_display_index(index) + return await self._raw._read_single(self._display_to_mdb(index)) + + async def read(self, start: int, end: int | None = None) -> ModbusResponse[TValue_co]: + if end is None: + value = await self._read_single_display(start) + return ModbusResponse({f"{self._bank}{start}": value}) + + if end <= start: + raise ValueError("End address must be greater than start address.") + self._validate_display_index(start) + self._validate_display_index(end) + + result: dict[str, TValue_co] = {} + for idx in range(start, end + 1): + result[f"{self._bank}{idx}"] = await self._read_single_display(idx) + return ModbusResponse(result) + + async def write( + self, + start: int, + data: bool | int | float | str | list[bool] | list[int] | list[float] | list[str], + ) -> None: + if isinstance(data, list): + for offset, value in enumerate(data): + index = start + offset + self._validate_display_index(index) + await self._raw.write(self._display_to_mdb(index), value) + return + + self._validate_display_index(start) + await self._raw.write(self._display_to_mdb(start), data) + + def __getitem__(self, key: int) -> Coroutine[Any, Any, TValue_co]: + if isinstance(key, slice): + raise TypeError("Slicing is not supported. Use read(start, end) for range reads.") + return self._read_single_display(key) + + def __repr__(self) -> str: + return f"" + + +class FixedAddressAccessor(Generic[TValue_co]): + """Fixed-address alias accessor (e.g. XD0u/YD0u).""" + + def __init__(self, plc: ClickClient, bank: str, index: int) -> None: + self._plc = plc + self._bank = bank + self._index = index + self._name = format_address_display(bank, index) + self._raw = cast(AddressAccessor[TValue_co], plc._get_accessor(bank)) + + async def read(self) -> ModbusResponse[TValue_co]: + return await self._raw.read(self._index) + + async def write( + self, + data: bool | int | float | str | list[bool] | list[int] | list[float] | list[str], + ) -> None: + await self._raw.write(self._index, data) + + def __repr__(self) -> str: + return f"" + + +# ============================================================================== +# AddressInterface +# ============================================================================== + + +class AddressInterface: + """String-based access to raw PLC addresses.""" + + def __init__(self, plc: ClickClient) -> None: + self._plc = plc + + async def read(self, address: str) -> ModbusResponse[PlcValue]: + """Read by address string. Supports 'df1' or 'df1-df10'.""" + if "-" in address: + parts = address.split("-", 1) + bank1, start = parse_address(parts[0]) + bank2, end = parse_address(parts[1]) + if bank1 != bank2: + raise ValueError("Inter-bank ranges are unsupported.") + + if bank1 in {"XD", "YD"}: + start_display_name = format_address_display(bank1, start) + end_display_name = format_address_display(bank1, end) + if start_display_name.endswith("u") or end_display_name.endswith("u"): + raise ValueError( + f"{bank1} ranges cannot include upper-byte addresses; read {bank1}0u separately." + ) + start_display = xd_yd_mdb_to_display(start) + end_display = xd_yd_mdb_to_display(end) + if end_display <= start_display: + raise ValueError("End address must be greater than start address.") + accessor = self._plc._get_display_accessor(bank1) + return await accessor.read(start_display, end_display) + + if end <= start: + raise ValueError("End address must be greater than start address.") + accessor = self._plc._get_accessor(bank1) + return await accessor.read(start, end) + + bank, index = parse_address(address) + accessor = self._plc._get_accessor(bank) + return await accessor.read(index) + + async def write( + self, + address: str, + data: bool | int | float | str | list, + ) -> None: + """Write by address string.""" + bank, index = parse_address(address) + accessor = self._plc._get_accessor(bank) + await accessor.write(index, data) + + +# ============================================================================== +# TagInterface +# ============================================================================== + + +class TagInterface: + """Access PLC values via tag nicknames.""" + + def __init__(self, plc: ClickClient) -> None: + self._plc = plc + + @staticmethod + def _resolve_tag_name(tags: Mapping[str, dict[str, str]], tag_name: str) -> str: + if tag_name in tags: + return tag_name + lowered = tag_name.lower() + matches = [name for name in tags if name.lower() == lowered] + if len(matches) == 1: + return matches[0] + available = list(tags.keys())[:5] + if len(matches) > 1: + raise KeyError( + f"Tag '{tag_name}' is ambiguous due to case-colliding names. Available: {available}" + ) + raise KeyError(f"Tag '{tag_name}' not found. Available: {available}") + + async def read(self, tag_name: str) -> PlcValue: + """Read a single tag value by name (case-insensitive).""" + tags = self._plc.tags + resolved_name = self._resolve_tag_name(tags, tag_name) + tag_info = tags[resolved_name] + resp = await self._plc.addr.read(tag_info["address"]) + return next(iter(resp.values())) + + async def read_all(self, *, include_system: bool = False) -> dict[str, PlcValue]: + """Read all tag values. + + Args: + include_system: If False (default), skip SC/SD system banks. + """ + tags = self._plc.tags + if not tags: + raise ValueError("No tags loaded. Provide tags to ClickClient or specify a tag name.") + + system_banks = frozenset({"SC", "SD"}) + all_tags: dict[str, PlcValue] = {} + for name, info in tags.items(): + if not include_system: + bank, _ = parse_address(info["address"]) + if bank in system_banks: + continue + resp = await self._plc.addr.read(info["address"]) + all_tags[name] = next(iter(resp.values())) + return all_tags + + async def write( + self, + tag_name: str, + data: bool | int | float | str | list, + ) -> None: + """Write value by tag name.""" + tags = self._plc.tags + resolved_name = self._resolve_tag_name(tags, tag_name) + tag_info = tags[resolved_name] + await self._plc.addr.write(tag_info["address"], data) + + +# ============================================================================== +# ClickClient +# ============================================================================== + + +class ClickClient: + """Async Modbus TCP driver for CLICK PLCs.""" + + data_types: ClassVar[dict[str, str]] = _DATA_TYPE_STR + + def __init__( + self, + host: str, + port: int = 502, + tags: Mapping[str, AddressRecord] | None = None, + timeout: int = 1, + device_id: int = 1, + reconnect_delay: float = 0.0, + reconnect_delay_max: float = 0.0, + ) -> None: + """Create a ClickClient. + + Args: + host: PLC hostname or IP. Legacy ``"host:port"`` format is accepted. + port: Modbus TCP port (default 502). + tags: Optional tag name → AddressRecord mapping for the tag interface. + timeout: Connection timeout in seconds. + device_id: Modbus device/unit ID (0–247). + reconnect_delay: Initial reconnect delay in seconds (0 = no reconnect). + reconnect_delay_max: Maximum reconnect backoff in seconds. + """ + # Backwards compatibility for legacy "host:port" first argument. + if ":" in host and port == 502: + host, port_str = host.rsplit(":", 1) + port = int(port_str) + + if not (1 <= port <= 65535): + raise ValueError("port must be in [1, 65535]") + if not (0 <= device_id <= 247): + raise ValueError("device_id must be in [0, 247]") + + self._client = AsyncModbusTcpClient( + host, + port=port, + timeout=timeout, + reconnect_delay=reconnect_delay, + reconnect_delay_max=reconnect_delay_max, + ) + self._device_id = device_id + self._accessors: dict[str, AddressAccessor[PlcValue]] = {} + self._display_accessors: dict[str, DisplayAddressAccessor[int]] = {} + self._upper_byte_accessors: dict[str, FixedAddressAccessor[int]] = { + "XD": FixedAddressAccessor(self, "XD", 1), + "YD": FixedAddressAccessor(self, "YD", 1), + } + self.tags: dict[str, dict[str, str]] = {} + self.xd0u = self._upper_byte_accessors["XD"] + self.yd0u = self._upper_byte_accessors["YD"] + self.addr = AddressInterface(self) + self.tag = TagInterface(self) + + if tags is not None: + self.tags = _build_tags_from_records(tags) + + async def _call_modbus(self, method: Any, /, **kwargs: Any) -> Any: + """Call pymodbus methods with device_id, falling back to legacy slave.""" + try: + return await method(device_id=self._device_id, **kwargs) + except TypeError as exc: + if "device_id" not in str(exc): + raise + return await method(slave=self._device_id, **kwargs) + + @overload + def _get_accessor(self, bank: BoolBankName) -> AddressAccessor[bool]: ... + + @overload + def _get_accessor(self, bank: IntBankName) -> AddressAccessor[int]: ... + + @overload + def _get_accessor(self, bank: FloatBankName) -> AddressAccessor[float]: ... + + @overload + def _get_accessor(self, bank: StrBankName) -> AddressAccessor[str]: ... + + @overload + def _get_accessor(self, bank: str) -> AddressAccessor[PlcValue]: ... + + def _get_accessor(self, bank: str) -> AddressAccessor[PlcValue]: + """Get or create an AddressAccessor for a bank.""" + bank_upper = bank.upper() + if bank_upper not in MODBUS_MAPPINGS: + raise AttributeError(f"'{bank}' is not a supported address type.") + if bank_upper not in self._accessors: + self._accessors[bank_upper] = cast( + AddressAccessor[PlcValue], AddressAccessor(self, bank_upper) + ) + return self._accessors[bank_upper] + + @overload + def __getattr__(self, name: BoolBankAttr) -> AddressAccessor[bool]: ... + + @overload + def __getattr__(self, name: DisplayBankAttr) -> DisplayAddressAccessor[int]: ... + + @overload + def __getattr__(self, name: UpperByteAttr) -> FixedAddressAccessor[int]: ... + + @overload + def __getattr__(self, name: IntBankAttr) -> AddressAccessor[int]: ... + + @overload + def __getattr__(self, name: FloatBankAttr) -> AddressAccessor[float]: ... + + @overload + def __getattr__(self, name: StrBankAttr) -> AddressAccessor[str]: ... + + def _get_display_accessor(self, bank: str) -> DisplayAddressAccessor[int]: + bank_upper = bank.upper() + if bank_upper not in {"XD", "YD"}: + raise AttributeError(f"'{bank}' is not a supported display-indexed address type.") + if bank_upper not in self._display_accessors: + self._display_accessors[bank_upper] = DisplayAddressAccessor(self, bank_upper) + return self._display_accessors[bank_upper] + + def __getattr__( + self, name: str + ) -> AddressAccessor[PlcValue] | DisplayAddressAccessor[int] | FixedAddressAccessor[int]: + if name.startswith("_"): + raise AttributeError(name) + upper = name.upper() + if upper in {"XD0U", "YD0U"}: + return self._upper_byte_accessors[upper[:2]] + if upper in {"XD", "YD"}: + return self._get_display_accessor(upper) + if upper not in MODBUS_MAPPINGS: + raise AttributeError(f"'{name}' is not a supported address type.") + return self._get_accessor(upper) + + async def __aenter__(self) -> ClickClient: + await self._client.connect() + return self + + async def __aexit__(self, *args: object) -> None: + self._client.close() + + # --- Internal Modbus wrappers --- + + async def _read_coils(self, address: int, count: int, bank: str) -> list[bool]: + """Read coils using appropriate function code.""" + mapping = MODBUS_MAPPINGS[bank] + if 2 in mapping.function_codes: + result = await self._call_modbus( + self._client.read_discrete_inputs, address=address, count=count + ) + else: + result = await self._call_modbus(self._client.read_coils, address=address, count=count) + if result.isError(): + raise OSError(f"Modbus read error at coil {address}: {result}") + return list(result.bits[:count]) + + async def _write_coils(self, address: int, values: list[bool]) -> None: + """Write coils.""" + if len(values) == 1: + result = await self._call_modbus( + self._client.write_coil, address=address, value=values[0] + ) + else: + result = await self._call_modbus( + self._client.write_coils, address=address, values=values + ) + if result.isError(): + raise OSError(f"Modbus write error at coil {address}: {result}") + + async def _read_registers(self, address: int, count: int, bank: str) -> list[int]: + """Read registers using appropriate function code.""" + mapping = MODBUS_MAPPINGS[bank] + if 4 in mapping.function_codes: + result = await self._call_modbus( + self._client.read_input_registers, address=address, count=count + ) + else: + result = await self._call_modbus( + self._client.read_holding_registers, address=address, count=count + ) + if result.isError(): + raise OSError(f"Modbus read error at register {address}: {result}") + return list(result.registers[:count]) + + async def _write_registers(self, address: int, values: list[int]) -> None: + """Write registers.""" + if len(values) == 1: + result = await self._call_modbus( + self._client.write_register, address=address, value=values[0] + ) + else: + result = await self._call_modbus( + self._client.write_registers, address=address, values=values + ) + if result.isError(): + raise OSError(f"Modbus write error at register {address}: {result}") diff --git a/src/pyclickplc/dataview.py b/src/pyclickplc/dataview.py new file mode 100644 index 0000000..bb7d068 --- /dev/null +++ b/src/pyclickplc/dataview.py @@ -0,0 +1,948 @@ +"""DataView model and CDV file I/O for CLICK PLC DataView files. + +Provides the DataViewRecord dataclass, CDV file read/write, +value conversion functions between CDV storage, native Python types, +UI display strings, and CDV verification helpers. +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass, field +from pathlib import Path +from typing import TypeAlias + +from .addresses import format_address_display, parse_address +from .banks import MEMORY_TYPE_TO_DATA_TYPE, DataType +from .validation import ( + FLOAT_MAX, + FLOAT_MIN, + INT2_MAX, + INT2_MIN, + INT_MAX, + INT_MIN, + validate_initial_value, +) + + +# Type codes used in CDV files to identify address types +class _CdvStorageCode: + """Type codes for CDV file format.""" + + BIT = 768 + INT = 0 + INT2 = 256 + HEX = 3 + FLOAT = 257 + TXT = 1024 + + +_CDV_CODE_TO_DATA_TYPE: dict[int, DataType] = { + _CdvStorageCode.BIT: DataType.BIT, + _CdvStorageCode.INT: DataType.INT, + _CdvStorageCode.INT2: DataType.INT2, + _CdvStorageCode.HEX: DataType.HEX, + _CdvStorageCode.FLOAT: DataType.FLOAT, + _CdvStorageCode.TXT: DataType.TXT, +} +_DATA_TYPE_TO_CDV_CODE: dict[DataType, int] = {v: k for k, v in _CDV_CODE_TO_DATA_TYPE.items()} + + +# SC addresses that are writable (most SC are read-only system controls) +WRITABLE_SC: frozenset[int] = frozenset({50, 51, 53, 55, 60, 61, 65, 66, 67, 75, 76, 120, 121}) + +# SD addresses that are writable (most SD are read-only system data) +WRITABLE_SD: frozenset[int] = frozenset( + { + 29, + 31, + 32, + 34, + 35, + 36, + 40, + 41, + 42, + 50, + 51, + 60, + 61, + 106, + 107, + 108, + 112, + 113, + 114, + 140, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 214, + 215, + } +) + +# Max rows in a dataview +MAX_DATAVIEW_ROWS = 100 +DataViewValue: TypeAlias = bool | int | float | str | None + + +def get_data_type_for_address(address: str) -> DataType | None: + """Get the DataType for an address. + + Args: + address: Address string like "X001", "DS1" + + Returns: + DataType or None if address is invalid. + """ + try: + memory_type, _ = parse_address(address) + except ValueError: + return None + data_type = MEMORY_TYPE_TO_DATA_TYPE.get(memory_type) + return DataType(data_type) if data_type is not None else None + + +def is_address_writable(address: str) -> bool: + """Check if an address is writable (can have a New Value set). + + Most addresses are writable, but SC and SD have specific writable addresses. + XD and YD are read-only. + + Args: + address: Address string like "X001", "SC50" + + Returns: + True if the address can have a New Value written to it. + """ + try: + memory_type, mdb_address = parse_address(address) + except ValueError: + return False + + # XD and YD are read-only + if memory_type in ("XD", "YD"): + return False + + # SC has specific writable addresses + if memory_type == "SC": + return mdb_address in WRITABLE_SC + + # SD has specific writable addresses + if memory_type == "SD": + return mdb_address in WRITABLE_SD + + # All other addresses are writable + return True + + +@dataclass +class DataViewRecord: + """Represents a single row in a CLICK DataView. + + A dataview row contains an address to monitor and optionally a new value + to write to that address. The nickname and comment are display-only + fields populated from SharedAddressData. + """ + + # Core data (stored in CDV file) + address: str = "" # e.g., "X001", "DS1", "CTD250" + data_type: DataType | None = None # DataType for the address + new_value: DataViewValue = None # Native Python value for optional write + + # Display-only fields (populated from SharedAddressData) + nickname: str = field(default="", compare=False) + comment: str = field(default="", compare=False) + + @property + def is_empty(self) -> bool: + """Check if this row is empty (no address set).""" + return not self.address.strip() + + @property + def is_writable(self) -> bool: + """Check if this address can have a New Value written to it.""" + return is_address_writable(self.address) + + @property + def memory_type(self) -> str | None: + """Get the memory type prefix (X, Y, DS, etc.) or None if invalid.""" + try: + mem_type, _ = parse_address(self.address) + return mem_type + except ValueError: + return None + + @property + def address_number(self) -> str | None: + """Get the address number as a display string, or None if invalid.""" + try: + memory_type, mdb_address = parse_address(self.address) + except ValueError: + return None + # Return the display address portion (strip the memory type prefix) + display = format_address_display(memory_type, mdb_address) + return display[len(memory_type) :] + + def update_data_type(self) -> bool: + """Update the DataType based on the current address. + + Returns: + True if data_type was updated, False if address is invalid. + """ + data_type = get_data_type_for_address(self.address) + if data_type is not None: + self.data_type = data_type + return True + return False + + def clear(self) -> None: + """Clear all fields in this row.""" + self.address = "" + self.data_type = None + self.new_value = None + self.nickname = "" + self.comment = "" + + +def create_empty_dataview(count: int = MAX_DATAVIEW_ROWS) -> list[DataViewRecord]: + """Create a new empty dataview with the specified number of rows. + + Args: + count: Number of rows to create (default MAX_DATAVIEW_ROWS). + + Returns: + List of empty DataViewRecord objects. + """ + return [DataViewRecord() for _ in range(count)] + + +# --- Value Conversion Functions --- +# +# Two layers of conversion: +# 1. storage <-> datatype: CDV file strings <-> native Python types +# 2. datatype <-> display: native Python types <-> UI-friendly strings +# +# The storage layer handles CDV encoding (sign extension, IEEE 754, etc.). +# The display layer handles presentation (hex formatting, float precision, etc.). + + +def storage_to_datatype(value: str, data_type: DataType) -> int | float | bool | str | None: + """Convert a CDV storage string to its native Python type. + + Args: + value: The raw value string from the CDV file. + data_type: DataType for conversion. + + Returns: + Native Python value (bool for BIT, int for INT/INT2/HEX, + float for FLOAT, str for TXT), or None if empty/invalid. + """ + if not value: + return None + + try: + if data_type == DataType.BIT: + return value == "1" + + if data_type == DataType.INT: + # Stored as unsigned 32-bit with sign extension -> signed 16-bit + unsigned_val = int(value) + val_16bit = unsigned_val & 0xFFFF + if val_16bit >= 0x8000: + val_16bit -= 0x10000 + return val_16bit + + if data_type == DataType.INT2: + # Stored as unsigned 32-bit -> signed 32-bit + unsigned_val = int(value) + if unsigned_val >= 0x80000000: + unsigned_val -= 0x100000000 + return unsigned_val + + if data_type == DataType.HEX: + return int(value) + + if data_type == DataType.FLOAT: + # Stored as IEEE 754 32-bit integer representation -> float + int_val = int(value) + bytes_val = struct.pack(">I", int_val & 0xFFFFFFFF) + return struct.unpack(">f", bytes_val)[0] + + if data_type == DataType.TXT: + code = int(value) + return chr(code) if 0 < code < 128 else "" + + return None + + except (ValueError, struct.error): + return None + + +def datatype_to_storage(value: int | float | bool | str | None, data_type: DataType) -> str: + """Convert a native Python value to CDV storage format. + + Args: + value: The native Python value (bool, int, or float). + data_type: DataType for conversion. + + Returns: + Value formatted for CDV file storage, or "" if None. + """ + if value is None: + return "" + + try: + if data_type == DataType.BIT: + return "1" if value else "0" + + if data_type == DataType.INT: + # Signed 16-bit -> unsigned 32-bit with sign extension + signed_val = int(value) + signed_val = max(-32768, min(32767, signed_val)) + if signed_val < 0: + return str(signed_val + 0x100000000) + return str(signed_val) + + if data_type == DataType.INT2: + # Signed 32-bit -> unsigned 32-bit + signed_val = int(value) + signed_val = max(-2147483648, min(2147483647, signed_val)) + if signed_val < 0: + return str(signed_val + 0x100000000) + return str(signed_val) + + if data_type == DataType.HEX: + return str(int(value)) + + if data_type == DataType.FLOAT: + # Float -> IEEE 754 bytes -> unsigned 32-bit integer string + float_val = float(value) + bytes_val = struct.pack(">f", float_val) + int_val = struct.unpack(">I", bytes_val)[0] + return str(int_val) + + if data_type == DataType.TXT: + if isinstance(value, str): + return str(ord(value)) if value else "0" + return str(int(value)) + + return "" + + except (ValueError, struct.error): + return "" + + +def datatype_to_display(value: int | float | bool | str | None, data_type: DataType) -> str: + """Convert a native Python value to a UI-friendly display string. + + Args: + value: The native Python value (bool, int, or float). + data_type: DataType for conversion. + + Returns: + Human-readable display string, or "" if None. + """ + if value is None: + return "" + + try: + if data_type == DataType.BIT: + return "1" if value else "0" + + if data_type in (DataType.INT, DataType.INT2): + return str(int(value)) + + if data_type == DataType.HEX: + return format(int(value), "04X") + + if data_type == DataType.FLOAT: + return f"{float(value):.7G}" + + if data_type == DataType.TXT: + if isinstance(value, str): + return value if value else "" + code = int(value) + if 32 <= code <= 126: + return chr(code) + return str(code) + + return str(value) + + except (ValueError, TypeError): + return "" + + +def display_to_datatype(value: str, data_type: DataType) -> int | float | bool | str | None: + """Convert a UI display string to its native Python type. + + Args: + value: The human-readable display string. + data_type: DataType for conversion. + + Returns: + Native Python value (bool for BIT, int for INT/INT2/HEX, + float for FLOAT, str for TXT), or None if empty/invalid. + """ + if not value: + return None + + try: + if data_type == DataType.BIT: + return value in ("1", "True", "true", "ON", "on") + + if data_type in (DataType.INT, DataType.INT2): + return int(value) + + if data_type == DataType.HEX: + hex_val = value.strip() + if hex_val.lower().startswith("0x"): + hex_val = hex_val[2:] + return int(hex_val, 16) + + if data_type == DataType.FLOAT: + return float(value) + + if data_type == DataType.TXT: + if len(value) == 1: + return value + code = int(value) + return chr(code) if 0 < code < 128 else "" + + return None + + except (ValueError, TypeError): + return None + + +def validate_new_value(display_str: str, data_type: DataType) -> tuple[bool, str]: + """Validate a user-entered display string for the New Value column.""" + if not display_str: + return True, "" + return validate_initial_value(display_str, data_type) + + +# ============================================================================= +# CDV File I/O +# ============================================================================= + + +@dataclass(frozen=True) +class DisplayParseResult: + """Result object for non-throwing display -> native parsing.""" + + ok: bool + value: DataViewValue = None + error: str = "" + + +def _default_cdv_header(has_new_values: bool) -> str: + return f"{-1 if has_new_values else 0},0,0" + + +def _rows_snapshot(rows: list[DataViewRecord]) -> list[tuple[str, DataType | None, DataViewValue]]: + return [(row.address, row.data_type, row.new_value) for row in rows] + + +def _coerce_for_compare(value: DataViewValue, data_type: DataType) -> DataViewValue: + if value is None: + return None + + if data_type == DataType.BIT: + if isinstance(value, bool): + return value + if isinstance(value, int): + if value in (0, 1): + return bool(value) + return None + if isinstance(value, float): + if value.is_integer() and int(value) in (0, 1): + return bool(int(value)) + return None + return None + + if data_type in (DataType.INT, DataType.INT2, DataType.HEX): + if isinstance(value, bool): + return int(value) + if isinstance(value, int): + return value + if isinstance(value, float): + if value.is_integer(): + return int(value) + return None + return None + + if data_type == DataType.FLOAT: + try: + return float(value) + except (TypeError, ValueError): + return None + + if data_type == DataType.TXT: + if isinstance(value, str): + return value + if isinstance(value, int) and 0 <= value <= 127: + return chr(value) + return None + + return value + + +def _values_equal_for_data_type( + expected: DataViewValue, + actual: DataViewValue, + data_type: DataType | None, +) -> bool: + if expected is None and actual is None: + return True + if data_type is None: + return expected == actual + + expected_norm = _coerce_for_compare(expected, data_type) + actual_norm = _coerce_for_compare(actual, data_type) + + if expected_norm is None or actual_norm is None: + return expected == actual + + if data_type == DataType.FLOAT: + return abs(float(expected_norm) - float(actual_norm)) <= 1e-6 + + return expected_norm == actual_norm + + +def _row_placeholder(rows: list[DataViewRecord], index: int) -> DataViewRecord: + return rows[index] if index < len(rows) else DataViewRecord() + + +@dataclass +class DataViewFile: + """CDV file model with row data in native Python types.""" + + rows: list[DataViewRecord] = field(default_factory=create_empty_dataview) + has_new_values: bool = False + header: str = "0,0,0" + path: Path | None = None + _original_bytes: bytes | None = field(default=None, repr=False, compare=False) + _original_rows: list[tuple[str, DataType | None, DataViewValue]] = field( + default_factory=list, + repr=False, + compare=False, + ) + _original_has_new_values: bool | None = field(default=None, repr=False, compare=False) + _original_header: str | None = field(default=None, repr=False, compare=False) + _row_storage_tokens: list[str | None] = field(default_factory=list, repr=False, compare=False) + + @staticmethod + def value_to_display(value: DataViewValue, data_type: DataType | None) -> str: + """Render a native value as a display string.""" + if data_type is None: + return "" + return datatype_to_display(value, data_type) + + @staticmethod + def validate_display(display_str: str, data_type: DataType | None) -> tuple[bool, str]: + """Validate display text for a target data type.""" + if data_type is None: + return False, "No address set" + return validate_new_value(display_str, data_type) + + @staticmethod + def try_parse_display(display_str: str, data_type: DataType | None) -> DisplayParseResult: + """Parse a display string to a native value without raising.""" + if not display_str: + return DisplayParseResult(ok=True, value=None) + ok, error = DataViewFile.validate_display(display_str, data_type) + if not ok or data_type is None: + return DisplayParseResult(ok=False, error=error) + native = display_to_datatype(display_str, data_type) + if native is None: + return DisplayParseResult(ok=False, error="Invalid value") + return DisplayParseResult(ok=True, value=native) + + @staticmethod + def validate_row_display(row: DataViewRecord, display_str: str) -> tuple[bool, str]: + """Validate a user edit for a specific row.""" + if not row.is_writable: + return False, "Read-only address" + return DataViewFile.validate_display(display_str, row.data_type) + + @staticmethod + def set_row_new_value_from_display(row: DataViewRecord, display_str: str) -> None: + """Strictly set a row's native new_value from a display string.""" + ok, error = DataViewFile.validate_row_display(row, display_str) + if not ok: + raise ValueError(error) + parsed = DataViewFile.try_parse_display(display_str, row.data_type) + if not parsed.ok: + raise ValueError(parsed.error) + row.new_value = parsed.value + + @classmethod + def load(cls, path: Path | str) -> DataViewFile: + """Load a CDV file and parse new values into native Python types.""" + path_obj = Path(path) + if not path_obj.exists(): + raise FileNotFoundError(f"CDV file not found: {path_obj}") + + raw_bytes = path_obj.read_bytes() + content = raw_bytes.decode("utf-16") + lines = content.splitlines() + if not lines: + raise ValueError(f"Empty CDV file: {path_obj}") + + header = lines[0].strip() + header_parts = [p.strip() for p in header.split(",")] + if len(header_parts) < 1: + raise ValueError(f"Invalid CDV header: {header}") + + try: + has_new_values = int(header_parts[0]) == -1 + except ValueError: + has_new_values = False + + rows = create_empty_dataview() + row_storage_tokens: list[str | None] = [None] * MAX_DATAVIEW_ROWS + + for i, raw_line in enumerate(lines[1 : MAX_DATAVIEW_ROWS + 1]): + line = raw_line.strip() + if not line: + continue + + parts = [p.strip() for p in line.split(",")] + if not parts[0]: + continue + + row = rows[i] + row.address = parts[0] + + if len(parts) > 1 and parts[1]: + try: + cdv_code = int(parts[1]) + row.data_type = _CDV_CODE_TO_DATA_TYPE.get(cdv_code) + except ValueError: + row.data_type = None + + if row.data_type is None: + row.data_type = get_data_type_for_address(row.address) + + if len(parts) > 2 and parts[2]: + row_storage_tokens[i] = parts[2] + if row.data_type is None: + row.new_value = parts[2] + else: + row.new_value = storage_to_datatype(parts[2], row.data_type) + + dataview = cls( + rows=rows, + has_new_values=has_new_values, + header=header, + path=path_obj, + ) + dataview._original_bytes = raw_bytes + dataview._original_rows = _rows_snapshot(rows) + dataview._original_has_new_values = has_new_values + dataview._original_header = header + dataview._row_storage_tokens = row_storage_tokens + return dataview + + def _is_pristine(self) -> bool: + if self._original_bytes is None: + return False + if self._original_has_new_values is None or self._original_header is None: + return False + return ( + self.header == self._original_header + and self.has_new_values == self._original_has_new_values + and _rows_snapshot(self.rows) == self._original_rows + ) + + def _header_with_current_flag(self) -> str: + header = self.header or _default_cdv_header(self.has_new_values) + parts = [p.strip() for p in header.split(",")] + if not parts: + return _default_cdv_header(self.has_new_values) + parts[0] = "-1" if self.has_new_values else "0" + return ",".join(parts) + + def save(self, path: Path | str | None = None) -> None: + """Save CDV back to disk, converting native values at the file boundary.""" + target = Path(path) if path is not None else self.path + if target is None: + raise ValueError("No path provided for save") + + self.has_new_values = any( + row.new_value is not None for row in self.rows[:MAX_DATAVIEW_ROWS] if not row.is_empty + ) + self.header = self._header_with_current_flag() + + if self._is_pristine(): + target.write_bytes(self._original_bytes or b"") + self.path = target + return + + lines: list[str] = [self.header] + rows_to_save = list(self.rows[:MAX_DATAVIEW_ROWS]) + while len(rows_to_save) < MAX_DATAVIEW_ROWS: + rows_to_save.append(DataViewRecord()) + + new_storage_tokens: list[str | None] = [] + for row in rows_to_save: + if row.is_empty: + lines.append(",0") + new_storage_tokens.append(None) + continue + + data_type = ( + row.data_type + if row.data_type is not None + else get_data_type_for_address(row.address) + ) + cdv_code = ( + _CdvStorageCode.INT if data_type is None else _DATA_TYPE_TO_CDV_CODE[data_type] + ) + + if row.new_value is not None: + if data_type is None: + storage_value = str(row.new_value) + else: + storage_value = datatype_to_storage(row.new_value, data_type) + lines.append(f"{row.address},{cdv_code},{storage_value}") + new_storage_tokens.append(storage_value) + else: + lines.append(f"{row.address},{cdv_code}") + new_storage_tokens.append(None) + + content = "\n".join(lines) + "\n" + encoded = content.encode("utf-16") + target.write_bytes(encoded) + self.path = target + self._original_bytes = encoded + self._original_rows = _rows_snapshot(self.rows) + self._original_has_new_values = self.has_new_values + self._original_header = self.header + self._row_storage_tokens = new_storage_tokens + + def verify(self, path: Path | str | None = None) -> list[str]: + """Compare this in-memory dataview to a CDV file on disk.""" + target = Path(path) if path is not None else self.path + if target is None: + raise ValueError("No path provided for verify") + + disk = DataViewFile.load(target) + issues: list[str] = [] + + for i in range(MAX_DATAVIEW_ROWS): + expected = _row_placeholder(self.rows, i) + actual = _row_placeholder(disk.rows, i) + row_num = i + 1 + + if expected.address != actual.address: + issues.append( + f"CDV {target.name} row {row_num}: address mismatch " + f"(memory={expected.address!r}, file={actual.address!r})" + ) + continue + + if expected.is_empty and actual.is_empty: + continue + + if expected.data_type != actual.data_type: + issues.append( + f"CDV {target.name} row {row_num}: data_type mismatch " + f"(memory={expected.data_type}, file={actual.data_type})" + ) + continue + + compare_type = expected.data_type or actual.data_type + if not _values_equal_for_data_type(expected.new_value, actual.new_value, compare_type): + issues.append( + f"CDV {target.name} row {row_num}: new_value mismatch " + f"(memory={expected.new_value!r}, file={actual.new_value!r})" + ) + + if self.has_new_values != disk.has_new_values: + issues.append( + f"CDV {target.name}: has_new_values mismatch " + f"(memory={self.has_new_values}, file={disk.has_new_values})" + ) + + return issues + + +def read_cdv(path: Path | str) -> DataViewFile: + """Read a CDV file into a DataViewFile model.""" + return DataViewFile.load(path) + + +def write_cdv(path: Path | str, dataview: DataViewFile) -> None: + """Write a DataViewFile to a CDV path.""" + dataview.save(path) + + +def _validate_cdv_new_value( + new_value: str, + data_type: DataType, + address: str, + filename: str, + row_num: int, +) -> list[str]: + """Validate CDV new_value storage and logical ranges for a row.""" + issues: list[str] = [] + prefix = f"CDV {filename} row {row_num}: {address}" + + try: + if data_type == DataType.BIT: + if new_value not in ("0", "1"): + issues.append(f"{prefix} new_value '{new_value}' invalid for BIT (must be 0 or 1)") + return issues + + if data_type == DataType.INT: + raw = int(new_value) + if raw < 0 or raw > 0xFFFFFFFF: + issues.append(f"{prefix} new_value '{new_value}' out of range for INT storage") + return issues + converted = storage_to_datatype(new_value, data_type) + if not isinstance(converted, int): + issues.append(f"{prefix} new_value '{new_value}' failed to convert to INT") + return issues + if converted < INT_MIN or converted > INT_MAX: + issues.append( + f"{prefix} new_value converts to {converted}, " + f"outside INT range ({INT_MIN} to {INT_MAX})" + ) + return issues + + if data_type == DataType.INT2: + raw = int(new_value) + if raw < 0 or raw > 0xFFFFFFFF: + issues.append(f"{prefix} new_value '{new_value}' out of range for INT2 storage") + return issues + converted = storage_to_datatype(new_value, data_type) + if not isinstance(converted, int): + issues.append(f"{prefix} new_value '{new_value}' failed to convert to INT2") + return issues + if converted < INT2_MIN or converted > INT2_MAX: + issues.append( + f"{prefix} new_value converts to {converted}, " + f"outside INT2 range ({INT2_MIN} to {INT2_MAX})" + ) + return issues + + if data_type == DataType.HEX: + raw = int(new_value) + if raw < 0 or raw > 0xFFFF: + issues.append(f"{prefix} new_value '{new_value}' out of range for HEX (0-65535)") + return issues + + if data_type == DataType.FLOAT: + raw = int(new_value) + if raw < 0 or raw > 0xFFFFFFFF: + issues.append(f"{prefix} new_value '{new_value}' invalid for FLOAT storage") + return issues + converted = storage_to_datatype(new_value, data_type) + if not isinstance(converted, float): + issues.append(f"{prefix} new_value '{new_value}' failed to convert to FLOAT") + return issues + if converted < FLOAT_MIN or converted > FLOAT_MAX: + issues.append(f"{prefix} new_value converts to {converted}, outside FLOAT range") + return issues + + if data_type == DataType.TXT: + raw = int(new_value) + if raw < 0 or raw > 127: + issues.append( + f"{prefix} new_value '{new_value}' out of range for TXT (0-127 ASCII)" + ) + return issues + + except ValueError: + issues.append(f"{prefix} new_value '{new_value}' is not a valid number") + + return issues + + +def check_cdv_file(path: Path | str) -> list[str]: + """Validate a single CDV file and return issue strings.""" + issues: list[str] = [] + path = Path(path) + filename = path.name + + try: + dataview = DataViewFile.load(path) + except Exception as exc: # pragma: no cover - exercised by caller tests + return [f"CDV {filename}: Error loading file - {exc}"] + + for i, row in enumerate(dataview.rows): + if row.is_empty: + continue + + row_num = i + 1 + raw_new_value = ( + dataview._row_storage_tokens[i] if i < len(dataview._row_storage_tokens) else None + ) + + try: + memory_type, _mdb_address = parse_address(row.address) + except ValueError: + issues.append(f"CDV {filename} row {row_num}: Invalid address format '{row.address}'") + continue + + if memory_type not in MEMORY_TYPE_TO_DATA_TYPE: + issues.append(f"CDV {filename} row {row_num}: Unknown memory type '{memory_type}'") + continue + + expected_data_type = get_data_type_for_address(row.address) + if expected_data_type is not None and row.data_type != expected_data_type: + issues.append( + f"CDV {filename} row {row_num}: Data type mismatch for {row.address} " + f"(has {row.data_type}, expected {expected_data_type})" + ) + + if raw_new_value: + if row.data_type is None: + issues.append( + f"CDV {filename} row {row_num}: {row.address} has new_value but no data_type set" + ) + else: + issues.extend( + _validate_cdv_new_value( + raw_new_value, + row.data_type, + row.address, + filename, + row_num, + ) + ) + if not is_address_writable(row.address): + issues.append( + f"CDV {filename} row {row_num}: {row.address} has new_value " + f"but address is not writable" + ) + + return issues + + +def verify_cdv( + path: Path | str, + rows: list[DataViewRecord], + has_new_values: bool | None = None, +) -> list[str]: + """Verify in-memory rows against a CDV file using native value comparison.""" + has_values = has_new_values + if has_values is None: + has_values = any( + row.new_value is not None for row in rows[:MAX_DATAVIEW_ROWS] if not row.is_empty + ) + + dataview = DataViewFile( + rows=rows, + has_new_values=has_values, + header=_default_cdv_header(has_values), + path=Path(path), + ) + return dataview.verify(path) diff --git a/src/pyclickplc/modbus.py b/src/pyclickplc/modbus.py new file mode 100644 index 0000000..a1a19d6 --- /dev/null +++ b/src/pyclickplc/modbus.py @@ -0,0 +1,434 @@ +"""Modbus protocol mapping layer for CLICK PLCs. + +Maps PLC addresses to/from Modbus coil/register addresses, +and packs/unpacks Python values into Modbus registers. +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass + +from .banks import BANKS, DataType + +# ============================================================================== +# DataType-derived constants +# ============================================================================== + +# Number of Modbus registers per value for each DataType +MODBUS_WIDTH: dict[DataType, int] = { + DataType.BIT: 1, + DataType.INT: 1, + DataType.INT2: 2, + DataType.FLOAT: 2, + DataType.HEX: 1, + DataType.TXT: 1, +} + +# Whether the DataType is signed +MODBUS_SIGNED: dict[DataType, bool] = { + DataType.INT: True, + DataType.INT2: True, + DataType.HEX: False, + DataType.FLOAT: False, +} + +# struct format characters for numeric types +STRUCT_FORMATS: dict[DataType, str] = { + DataType.INT: "h", + DataType.INT2: "i", + DataType.FLOAT: "f", + DataType.HEX: "H", +} + +# ============================================================================== +# ModbusMapping +# ============================================================================== + + +@dataclass(frozen=True) +class ModbusMapping: + """Modbus address mapping configuration for a PLC memory bank.""" + + bank: str + base: int + function_codes: frozenset[int] + is_coil: bool + width: int = 1 + signed: bool = True + writable: frozenset[int] | None = None + + @property + def is_writable(self) -> bool: + """True if any write FC (5, 6, 15, 16) is in function_codes.""" + return bool(self.function_codes & {5, 6, 15, 16}) + + +# ============================================================================== +# Writable subsets (Modbus-specific, distinct from dataview.py) +# ============================================================================== + +# SC: Modbus-writable subset (excludes 50/51 which are ladder-only) +_MODBUS_WRITABLE_SC: frozenset[int] = frozenset({53, 55, 60, 61, 65, 66, 67, 75, 76, 120, 121}) + +# SD: Modbus-writable subset +_MODBUS_WRITABLE_SD: frozenset[int] = frozenset( + { + 29, + 31, + 32, + 34, + 35, + 36, + 40, + 41, + 42, + 50, + 51, + 60, + 61, + 106, + 107, + 108, + 112, + 113, + 114, + 140, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 214, + 215, + } +) + +# ============================================================================== +# MODBUS_MAPPINGS — all 16 banks +# ============================================================================== + +MODBUS_MAPPINGS: dict[str, ModbusMapping] = { + # --- Coil banks --- + "X": ModbusMapping("X", 0, frozenset({2}), is_coil=True), + "Y": ModbusMapping("Y", 8192, frozenset({1, 5, 15}), is_coil=True), + "C": ModbusMapping("C", 16384, frozenset({1, 5, 15}), is_coil=True), + "T": ModbusMapping("T", 45056, frozenset({2}), is_coil=True), + "CT": ModbusMapping("CT", 49152, frozenset({2}), is_coil=True), + "SC": ModbusMapping("SC", 61440, frozenset({2}), is_coil=True, writable=_MODBUS_WRITABLE_SC), + # --- Register banks --- + "DS": ModbusMapping("DS", 0, frozenset({3, 6, 16}), is_coil=False, width=1, signed=True), + "DD": ModbusMapping("DD", 16384, frozenset({3, 6, 16}), is_coil=False, width=2, signed=True), + "DH": ModbusMapping("DH", 24576, frozenset({3, 6, 16}), is_coil=False, width=1, signed=False), + "DF": ModbusMapping("DF", 28672, frozenset({3, 6, 16}), is_coil=False, width=2, signed=False), + "TXT": ModbusMapping("TXT", 36864, frozenset({3, 6, 16}), is_coil=False, width=1, signed=False), + "TD": ModbusMapping("TD", 45056, frozenset({3, 6, 16}), is_coil=False, width=1, signed=True), + "CTD": ModbusMapping("CTD", 49152, frozenset({3, 6, 16}), is_coil=False, width=2, signed=True), + "XD": ModbusMapping("XD", 57344, frozenset({4}), is_coil=False, width=1, signed=False), + "YD": ModbusMapping("YD", 57856, frozenset({3, 6, 16}), is_coil=False, width=1, signed=False), + "SD": ModbusMapping( + "SD", + 61440, + frozenset({4}), + is_coil=False, + width=1, + signed=False, + writable=_MODBUS_WRITABLE_SD, + ), +} + +# Split mappings by type for reverse lookups +_COIL_MAPPINGS: list[tuple[str, ModbusMapping]] = [ + (k, v) for k, v in MODBUS_MAPPINGS.items() if v.is_coil +] +_REGISTER_MAPPINGS: list[tuple[str, ModbusMapping]] = [ + (k, v) for k, v in MODBUS_MAPPINGS.items() if not v.is_coil +] + +# ============================================================================== +# Sparse coil helpers (private) +# ============================================================================== + +# X/Y sparse ranges from BankConfig +_SPARSE_RANGES_OPT = BANKS["X"].valid_ranges +assert _SPARSE_RANGES_OPT is not None +_SPARSE_RANGES: tuple[tuple[int, int], ...] = _SPARSE_RANGES_OPT + + +def _sparse_plc_to_coil(base: int, index: int) -> int: + """Forward map sparse PLC index to coil address. + + Uses the formula from CLICKDEVICE_SPEC: + - CPU slot 1 (001-016): base + index - 1 + - CPU slot 2 (021-036): base + 16 + (index - 21) + - Expansion (101+): base + 32 * hundred + (unit - 1) + """ + if index <= 16: + return base + index - 1 + if index <= 36: + return base + 16 + (index - 21) + hundred = index // 100 + unit = index % 100 + return base + 32 * hundred + (unit - 1) + + +def _sparse_coil_to_plc(bank: str, base: int, coil: int) -> tuple[str, int] | None: + """Reverse map coil address to (bank, index) or None for gaps. + + Uses the formula from CLICKSERVER_SPEC: + - offset < 16: CPU slot 1 -> *001-*016 + - offset < 32: CPU slot 2 -> *021-*036 + - else: expansion -> hundred*100 + unit + """ + offset = coil - base + if offset < 0: + return None + + if offset < 16: + return bank, offset + 1 + if offset < 32: + return bank, 21 + (offset - 16) + + # Expansion slots + hundred = offset // 32 + unit = (offset % 32) + 1 + if unit > 16: + return None # Gap + index = hundred * 100 + unit + # Validate against known ranges + if not any(lo <= index <= hi for lo, hi in _SPARSE_RANGES): + return None + return bank, index + + +# Total coil address space for sparse banks +# Last valid slot is 8xx (hundred=8), max offset = 8*32 + 15 = 271 +_SPARSE_COIL_SPAN = 8 * 32 + 16 # 272 + +# Coil spans for non-sparse banks +_COIL_SPANS: dict[str, int] = { + "C": BANKS["C"].max_addr, # 2000 + "T": BANKS["T"].max_addr, # 500 + "CT": BANKS["CT"].max_addr, # 250 + "SC": BANKS["SC"].max_addr, # 1000 +} + +# ============================================================================== +# Forward mapping: plc_to_modbus +# ============================================================================== + + +def plc_to_modbus(bank: str, index: int) -> tuple[int, int]: + """Map PLC address to (modbus_address, register_count). + + Args: + bank: Bank name (e.g. "X", "DS", "XD") + index: MDB index (e.g. 1 for X001, 0 for XD0, 2 for XD1) + + Returns: + Tuple of (modbus_address, register_count) + + Raises: + ValueError: If bank or index is invalid + """ + mapping = MODBUS_MAPPINGS.get(bank) + if mapping is None: + raise ValueError(f"Unknown bank: {bank!r}") + + if not _is_valid_index(bank, index): + raise ValueError(f"Invalid address: {bank}{index}") + + if mapping.is_coil: + bank_cfg = BANKS[bank] + if bank_cfg.valid_ranges is not None: + # Sparse coils (X/Y) + return _sparse_plc_to_coil(mapping.base, index), 1 + # Standard coils + return mapping.base + (index - 1), 1 + + # TXT is packed two chars per register. + if bank == "TXT": + return mapping.base + ((index - 1) // 2), 1 + + # Registers: 0-based banks (XD/YD) use base + index, + # 1-based banks use base + width * (index - 1) + bank_cfg = BANKS[bank] + if bank_cfg.min_addr == 0: + return mapping.base + index, 1 + return mapping.base + mapping.width * (index - 1), mapping.width + + +def _is_valid_index(bank: str, index: int) -> bool: + """Check if index is valid for the given bank.""" + bank_cfg = BANKS[bank] + if bank_cfg.valid_ranges is not None: + return any(lo <= index <= hi for lo, hi in bank_cfg.valid_ranges) + return bank_cfg.min_addr <= index <= bank_cfg.max_addr + + +# ============================================================================== +# Reverse mapping: modbus_to_plc +# ============================================================================== + + +def modbus_to_plc(address: int, is_coil: bool) -> tuple[str, int] | None: + """Reverse map Modbus address to (bank, display_index) or None. + + Args: + address: Raw Modbus coil or register address + is_coil: True for coil address space, False for register space + + Returns: + Tuple of (bank_name, display_index) or None if unmapped + """ + if is_coil: + return _reverse_coil(address) + return _reverse_register(address) + + +def _reverse_coil(address: int) -> tuple[str, int] | None: + """Reverse map a Modbus coil address to (bank, index).""" + for bank, mapping in _COIL_MAPPINGS: + bank_cfg = BANKS[bank] + if bank_cfg.valid_ranges is not None: + # Sparse bank (X/Y) + if mapping.base <= address < mapping.base + _SPARSE_COIL_SPAN: + return _sparse_coil_to_plc(bank, mapping.base, address) + else: + span = _COIL_SPANS[bank] + if mapping.base <= address < mapping.base + span: + return bank, address - mapping.base + 1 + return None + + +def _reverse_register(address: int) -> tuple[str, int] | None: + """Reverse map a Modbus register address to (bank, mdb_index).""" + for bank, mapping in _REGISTER_MAPPINGS: + bank_cfg = BANKS[bank] + max_mdb = bank_cfg.max_addr + if bank_cfg.min_addr == 0: + # 0-based banks (XD/YD): contiguous, base + mdb_index + end = mapping.base + max_mdb + 1 + if mapping.base <= address < end: + return bank, address - mapping.base + continue + + if bank == "TXT": + # TXT packs two addresses per register. Return the odd base index + # of the pair represented by this register. + txt_register_count = (max_mdb + 1) // 2 + end = mapping.base + txt_register_count + if mapping.base <= address < end: + return bank, (address - mapping.base) * 2 + 1 + continue + + # Standard 1-based register banks + end = mapping.base + mapping.width * max_mdb + if mapping.base <= address < end: + offset = address - mapping.base + if offset % mapping.width != 0: + return None # Mid-value register + return bank, offset // mapping.width + 1 + return None + + +def modbus_to_plc_register(address: int) -> tuple[str, int, int] | None: + """Extended reverse register mapping. + + Unlike modbus_to_plc(is_coil=False), this does NOT return None for + mid-value registers of width-2 types. Instead it returns the + reg_position (0 or 1) within the value. + + Needed by the server for FC 06 on width-2 types (read-modify-write). + + Returns: + (bank, mdb_index, reg_position) or None if unmapped + """ + for bank, mapping in _REGISTER_MAPPINGS: + bank_cfg = BANKS[bank] + max_mdb = bank_cfg.max_addr + if bank_cfg.min_addr == 0: + # 0-based banks (XD/YD): contiguous, width=1 + end = mapping.base + max_mdb + 1 + if mapping.base <= address < end: + return bank, address - mapping.base, 0 + continue + + if bank == "TXT": + # TXT packs two addresses per register. For a register address, + # return the odd base index of the pair. + txt_register_count = (max_mdb + 1) // 2 + end = mapping.base + txt_register_count + if mapping.base <= address < end: + return bank, (address - mapping.base) * 2 + 1, 0 + continue + + # Standard 1-based register banks + end = mapping.base + mapping.width * max_mdb + if mapping.base <= address < end: + offset = address - mapping.base + index = offset // mapping.width + 1 + reg_position = offset % mapping.width + return bank, index, reg_position + return None + + +# ============================================================================== +# Pack / Unpack +# ============================================================================== + + +def pack_value(value: bool | int | float | str, data_type: DataType) -> list[int]: + """Pack a Python value into Modbus register(s). + + Args: + value: The value to pack + data_type: The DataType determining the encoding + + Returns: + List of 16-bit register values + + Raises: + ValueError: If data_type is BIT (coils don't use register packing) + """ + if data_type == DataType.BIT: + raise ValueError("BIT values use coils, not registers") + + if data_type == DataType.TXT: + return [ord(str(value)) & 0xFFFF] + + fmt = STRUCT_FORMATS[data_type] + raw = struct.pack(f"<{fmt}", value) + + if len(raw) == 2: + return [struct.unpack(" int | float | str: + """Unpack Modbus register(s) into a Python value. + + Args: + registers: List of 16-bit register values + data_type: The DataType determining the decoding + + Returns: + The unpacked Python value + """ + if data_type == DataType.TXT: + return chr(registers[0] & 0xFF) + + fmt = STRUCT_FORMATS[data_type] + + if len(registers) == 1: + raw = struct.pack(" None: + if self.delay_s < 0: + raise ValueError("reconnect delay_s must be >= 0") + if self.max_delay_s < 0: + raise ValueError("reconnect max_delay_s must be >= 0") + if self.max_delay_s < self.delay_s: + raise ValueError("reconnect max_delay_s must be >= delay_s") + + +def _default_for_bank(bank: str) -> PlcValue: + data_type = BANKS[bank].data_type + if data_type == DataType.BIT: + return False + if data_type == DataType.FLOAT: + return 0.0 + if data_type == DataType.TXT: + return "\x00" + return 0 + + +def _is_writable(bank: str, index: int) -> bool: + mapping = MODBUS_MAPPINGS[bank] + if mapping.writable is not None: + return index in mapping.writable + return mapping.is_writable + + +def _modbus_count_for_span(bank: str, start: int, end: int) -> int: + if end < start: + raise ValueError("end must be >= start") + + mapping = MODBUS_MAPPINGS[bank] + if mapping.is_coil: + return end - start + 1 + + if bank == "TXT": + first = (start - 1) // 2 + last = (end - 1) // 2 + return last - first + 1 + + return (end - start + 1) * mapping.width + + +def _max_span_count(bank: str, *, for_write: bool) -> int: + mapping = MODBUS_MAPPINGS[bank] + if mapping.is_coil: + return MAX_WRITE_COILS if for_write else MAX_READ_COILS + return MAX_WRITE_REGISTERS if for_write else MAX_READ_REGISTERS + + +def _build_spans(bank: str, indices: Iterable[int], *, for_write: bool) -> list[_ReadSpan]: + sorted_indices = sorted(set(indices)) + if not sorted_indices: + return [] + + spans: list[_ReadSpan] = [] + limit = _max_span_count(bank, for_write=for_write) + start = sorted_indices[0] + prev = start + + for idx in sorted_indices[1:]: + contiguous = idx == prev + 1 + within_limit = _modbus_count_for_span(bank, start, idx) <= limit + if contiguous and within_limit: + prev = idx + continue + + spans.append(_ReadSpan(bank=bank, start=start, end=prev)) + start = idx + prev = idx + + spans.append(_ReadSpan(bank=bank, start=start, end=prev)) + return spans + + +def _normalize_addresses_for_read( + addresses: Iterable[str], +) -> tuple[list[str], dict[str, tuple[str, int]]]: + ordered: list[str] = [] + parsed: dict[str, tuple[str, int]] = {} + seen: set[str] = set() + for address in addresses: + if not isinstance(address, str): + raise ValueError(f"Invalid address format: {address!r}") + normalized = normalize_address(address) + if normalized is None: + raise ValueError(f"Invalid address format: {address!r}") + if normalized not in seen: + seen.add(normalized) + ordered.append(normalized) + parsed[normalized] = parse_address(normalized) + return ordered, parsed + + +def _build_read_plan( + ordered: Iterable[str], parsed: Mapping[str, tuple[str, int]] +) -> list[_ReadSpan]: + by_bank: dict[str, list[int]] = {} + for address in ordered: + bank, index = parsed[address] + by_bank.setdefault(bank, []).append(index) + + plan: list[_ReadSpan] = [] + for bank, indices in by_bank.items(): + plan.extend(_build_spans(bank, indices, for_write=False)) + return plan + + +class ModbusService: + """Synchronous wrapper over ClickClient with background polling.""" + + def __init__( + self, + poll_interval_s: float = 1.5, + reconnect: ReconnectConfig | None = None, + on_state: Callable[[ConnectionState, Exception | None], None] | None = None, + on_values: Callable[[ModbusResponse[PlcValue]], None] | None = None, + ) -> None: + """Create a ModbusService. + + Args: + poll_interval_s: Seconds between poll cycles (must be > 0). + reconnect: Optional reconnect backoff configuration. + on_state: Callback fired on connection state changes. + on_values: Callback fired with polled values each cycle. + """ + if poll_interval_s <= 0: + raise ValueError("poll_interval_s must be > 0") + + self._poll_interval_s = poll_interval_s + self._reconnect = reconnect + self._on_state = on_state + self._on_values = on_values + + self._state = ConnectionState.DISCONNECTED + self._client: ClickClient | None = None + self._poll_task: asyncio.Task[None] | None = None + self._poll_reads_failing = False + self._poll_config = _PollConfig(addresses=(), plan=(), enabled=True) + + self._thread_lock = threading.Lock() + self._loop_ready = threading.Event() + self._loop: asyncio.AbstractEventLoop | None = None + self._thread: threading.Thread | None = None + self._start_loop_thread() + + # Lifecycle -------------------------------------------------------------- + + def connect( + self, + host: str, + port: int = 502, + *, + device_id: int = 1, + timeout: int = 1, + ) -> None: + """Connect to a CLICK PLC endpoint. + + Args: + host: PLC hostname or IP address. + port: Modbus TCP port. + device_id: Modbus unit/device ID. + timeout: Client timeout in seconds. + + Raises: + OSError: Connection attempt failed. + ValueError: Invalid connection arguments. + """ + self._submit_wait(self._connect_async(host, port, device_id=device_id, timeout=timeout)) + + def disconnect(self) -> None: + """Disconnect from the PLC and stop the service loop thread.""" + thread = self._thread + if thread is None: + return + + try: + self._submit_wait(self._disconnect_async()) + finally: + if threading.current_thread() is not thread: + self._stop_loop_thread() + + def close(self) -> None: + """Alias for :meth:`disconnect`.""" + self.disconnect() + + # Poll configuration ----------------------------------------------------- + + def set_poll_addresses(self, addresses: Iterable[str]) -> None: + """Replace the active polling address set. + + Args: + addresses: Address strings to poll each cycle. + + Raises: + ValueError: Any address is invalid. + """ + normalized, parsed = _normalize_addresses_for_read(addresses) + plan = _build_read_plan(normalized, parsed) + self._submit_wait(self._set_poll_config_async(tuple(normalized), tuple(plan), enabled=True)) + + def clear_poll_addresses(self) -> None: + """Clear the active polling address set.""" + self._submit_wait(self._set_poll_config_async((), (), enabled=True)) + + def stop_polling(self) -> None: + """Pause polling while keeping the current configured address set.""" + self._submit_wait( + self._set_poll_config_async( + self._poll_config.addresses, + self._poll_config.plan, + enabled=False, + ) + ) + + # Sync operations -------------------------------------------------------- + + def read(self, addresses: Iterable[str]) -> ModbusResponse[PlcValue]: + """Synchronously read one or more addresses. + + Args: + addresses: Address strings to read. + + Returns: + ModbusResponse keyed by canonical normalized addresses. + + Raises: + ValueError: Any address is invalid. + OSError: Not connected or transport/protocol read failure. + """ + normalized, parsed = _normalize_addresses_for_read(addresses) + plan = _build_read_plan(normalized, parsed) + result = self._submit_wait(self._read_plan_async(plan)) + ordered_result: dict[str, PlcValue] = {} + for address in normalized: + ordered_result[address] = result.get(address, _default_for_bank(parsed[address][0])) + return ModbusResponse(ordered_result) + + def write( + self, + values: Mapping[str, PlcValue] | Iterable[tuple[str, PlcValue]], + ) -> list[WriteResult]: + """Synchronously write one or more address values. + + Args: + values: Mapping or iterable of ``(address, value)`` pairs. + + Returns: + Per-item write outcomes preserving input order. + Validation and write failures are reported in each ``WriteResult``. + """ + items: list[tuple[object, object]] + if isinstance(values, Mapping): + items = list(values.items()) + else: + items = list(values) + + return self._submit_wait(self._write_async(items)) + + # Internal loop bridge --------------------------------------------------- + + def _run_loop(self) -> None: + loop = asyncio.new_event_loop() + self._loop = loop + asyncio.set_event_loop(loop) + self._loop_ready.set() + try: + loop.run_forever() + finally: + pending = asyncio.all_tasks(loop) + for task in pending: + task.cancel() + if pending: + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + loop.close() + with self._thread_lock: + if self._thread is threading.current_thread(): + self._loop = None + + def _start_loop_thread(self) -> None: + with self._thread_lock: + if self._thread is not None and self._thread.is_alive(): + return + self._loop_ready = threading.Event() + self._loop = None + self._thread = threading.Thread( + target=self._run_loop, name="pyclickplc-modbus-service", daemon=True + ) + self._thread.start() + + if not self._loop_ready.wait(timeout=5): + raise RuntimeError("ModbusService event loop thread failed to start") + + def _ensure_loop_thread(self) -> None: + with self._thread_lock: + thread = self._thread + loop = self._loop + ready = self._loop_ready + + if thread is None or not thread.is_alive() or loop is None or loop.is_closed(): + self._start_loop_thread() + return + + if not ready.is_set() and not ready.wait(timeout=5): + raise RuntimeError("ModbusService event loop thread failed to start") + + def _stop_loop_thread(self) -> None: + with self._thread_lock: + thread = self._thread + loop = self._loop + + if thread is None: + return + if threading.current_thread() is thread: + raise RuntimeError( + "Synchronous ModbusService methods cannot be called from service callbacks" + ) + + if loop is not None and loop.is_running(): + loop.call_soon_threadsafe(loop.stop) + + thread.join(timeout=5) + if thread.is_alive(): + raise RuntimeError("ModbusService event loop thread failed to stop") + + with self._thread_lock: + if self._thread is thread: + self._thread = None + if self._loop is loop: + self._loop = None + + def _submit_wait(self, coro: Coroutine[Any, Any, T]) -> T: + try: + self._ensure_loop_thread() + except Exception: + coro.close() + raise + + loop = self._loop + thread = self._thread + if loop is None or thread is None: + coro.close() + raise RuntimeError("Service event loop is not available") + if threading.current_thread() is thread: + coro.close() + raise RuntimeError( + "Synchronous ModbusService methods cannot be called from service callbacks" + ) + future = asyncio.run_coroutine_threadsafe(coro, loop) + return future.result() + + def __del__(self) -> None: + try: + self._stop_loop_thread() + except Exception: + return + + # Async implementation --------------------------------------------------- + + async def _connect_async( + self, + host: str, + port: int, + *, + device_id: int, + timeout: int, + ) -> None: + if self._client is not None: + await self._disconnect_async() + + self._poll_reads_failing = False + self._emit_state(ConnectionState.CONNECTING, None) + candidate: ClickClient | None = None + try: + reconnect_delay = self._reconnect.delay_s if self._reconnect is not None else 0.0 + reconnect_delay_max = ( + self._reconnect.max_delay_s if self._reconnect is not None else 0.0 + ) + candidate = ClickClient( + host, + port, + timeout=timeout, + device_id=device_id, + reconnect_delay=reconnect_delay, + reconnect_delay_max=reconnect_delay_max, + ) + await candidate.__aenter__() + if not candidate._client.connected: # pyright: ignore[reportPrivateUsage] + raise OSError(f"Failed to connect to {host}:{port}") + self._client = candidate + self._poll_reads_failing = False + self._ensure_poll_task() + self._emit_state(ConnectionState.CONNECTED, None) + except Exception as exc: + if candidate is not None: + await candidate.__aexit__(None, None, None) + if isinstance(exc, (OSError, ValueError)): + self._emit_state(ConnectionState.ERROR, exc) + raise + + wrapped = OSError(str(exc)) + self._emit_state(ConnectionState.ERROR, wrapped) + raise wrapped from exc + + async def _disconnect_async(self) -> None: + poll_task = self._poll_task + self._poll_task = None + if poll_task is not None: + poll_task.cancel() + await asyncio.gather(poll_task, return_exceptions=True) + + client = self._client + self._client = None + self._poll_reads_failing = False + if client is not None: + await client.__aexit__(None, None, None) + + self._emit_state(ConnectionState.DISCONNECTED, None) + + async def _set_poll_config_async( + self, + addresses: tuple[str, ...], + plan: tuple[_ReadSpan, ...], + *, + enabled: bool, + ) -> None: + self._poll_config = _PollConfig(addresses=addresses, plan=plan, enabled=enabled) + + def _ensure_poll_task(self) -> None: + if self._poll_task is None or self._poll_task.done(): + self._poll_task = asyncio.create_task(self._poll_loop(), name="pyclickplc-poll-loop") + + async def _poll_loop(self) -> None: + try: + while True: + await asyncio.sleep(self._poll_interval_s) + + if self._client is None: + continue + + poll_config = self._poll_config + if not poll_config.enabled or not poll_config.addresses: + if self._poll_reads_failing: + self._poll_reads_failing = False + self._emit_state(ConnectionState.CONNECTED, None) + continue + + try: + data = await self._read_plan_async(list(poll_config.plan)) + except asyncio.CancelledError: + raise + except Exception as exc: + error = exc if isinstance(exc, OSError) else OSError(str(exc)) + if not self._poll_reads_failing: + self._poll_reads_failing = True + self._emit_state(ConnectionState.ERROR, error) + continue + + if self._poll_reads_failing: + self._poll_reads_failing = False + self._emit_state(ConnectionState.CONNECTED, None) + + ordered: dict[str, PlcValue] = {} + for address in poll_config.addresses: + ordered[address] = data[address] + + if self._on_values is not None: + try: + self._on_values(ModbusResponse(ordered)) + except Exception: + continue + except asyncio.CancelledError: + return + + async def _read_plan_async(self, plan: list[_ReadSpan]) -> dict[str, PlcValue]: + client = self._client + if client is None: + raise OSError("Not connected") + + merged: dict[str, PlcValue] = {} + for span in plan: + accessor = client._get_accessor(span.bank) # pyright: ignore[reportPrivateUsage] + if span.start == span.end: + response = await accessor.read(span.start) + else: + response = await accessor.read(span.start, span.end) + merged.update(response) + return merged + + async def _write_async(self, items: list[tuple[object, object]]) -> list[WriteResult]: + if not items: + return [] + + results: list[WriteResult] = [ + {"address": str(addr), "ok": False, "error": None} for addr, _ in items + ] + + validated: list[_WriteItem] = [] + for pos, (address, value) in enumerate(items): + if not isinstance(address, str): + results[pos] = { + "address": str(address), + "ok": False, + "error": "Invalid address format", + } + continue + normalized = normalize_address(address) + if normalized is None: + results[pos] = { + "address": str(address), + "ok": False, + "error": "Invalid address format", + } + continue + + bank, index = parse_address(normalized) + + if not _is_writable(bank, index): + results[pos] = { + "address": normalized, + "ok": False, + "error": f"{bank}{index} is not writable.", + } + continue + + try: + assert_runtime_value(BANKS[bank].data_type, value, bank=bank, index=index) + except ValueError as exc: + results[pos] = {"address": normalized, "ok": False, "error": str(exc)} + continue + + validated.append( + _WriteItem( + pos=pos, + normalized=normalized, + bank=bank, + index=index, + value=cast(PlcValue, value), + ) + ) + + batches = self._build_write_batches(validated) + client = self._client + if client is None: + error_text = "Not connected" + for item in validated: + results[item.pos] = {"address": item.normalized, "ok": False, "error": error_text} + return results + + for batch in batches: + accessor = client._get_accessor(batch.bank) # pyright: ignore[reportPrivateUsage] + try: + if len(batch.items) == 1: + one = batch.items[0] + await accessor.write(one.index, one.value) + else: + start = batch.items[0].index + values = [item.value for item in batch.items] + payload = cast( + "list[bool] | list[int] | list[float] | list[str]", + values, + ) + await accessor.write(start, payload) + + for item in batch.items: + results[item.pos] = {"address": item.normalized, "ok": True, "error": None} + except (OSError, ValueError) as exc: + for item in batch.items: + results[item.pos] = {"address": item.normalized, "ok": False, "error": str(exc)} + + return results + + def _build_write_batches(self, items: list[_WriteItem]) -> list[_WriteBatch]: + if not items: + return [] + + batches: list[_WriteBatch] = [] + current: list[_WriteItem] = [items[0]] + + for item in items[1:]: + prev = current[-1] + same_bank = item.bank == prev.bank + contiguous_index = item.index == prev.index + 1 + contiguous_input_order = item.pos == prev.pos + 1 + + if same_bank and contiguous_index and contiguous_input_order: + start_index = current[0].index + if _modbus_count_for_span(item.bank, start_index, item.index) <= _max_span_count( + item.bank, for_write=True + ): + current.append(item) + continue + + batches.append(_WriteBatch(bank=current[0].bank, items=tuple(current))) + current = [item] + + batches.append(_WriteBatch(bank=current[0].bank, items=tuple(current))) + return batches + + def _emit_state(self, state: ConnectionState, error: Exception | None) -> None: + if state is self._state and error is None: + return + self._state = state + if self._on_state is not None: + try: + self._on_state(state, error) + except Exception: + return diff --git a/src/pyclickplc/nicknames.py b/src/pyclickplc/nicknames.py new file mode 100644 index 0000000..171ffe6 --- /dev/null +++ b/src/pyclickplc/nicknames.py @@ -0,0 +1,326 @@ +"""CSV read/write for CLICK PLC address nicknames. + +Provides functions to read and write address data in CLICK software CSV format +(user-facing) and MDB-dump CSV format, using AddressRecord as the data model. +""" + +from __future__ import annotations + +import csv +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from .addresses import AddressNormalizerMixin, AddressRecord, get_addr_key, parse_address +from .banks import BANKS, DEFAULT_RETENTIVE, MEMORY_TYPE_BASES, MEMORY_TYPE_TO_DATA_TYPE, DataType + +# CSV column names (matching CLICK software export format) +CSV_COLUMNS = ["Address", "Data Type", "Nickname", "Initial Value", "Retentive", "Address Comment"] + +# Data type string to code mapping +DATA_TYPE_STR_TO_CODE: dict[str, int] = { + "BIT": 0, + "INT": 1, + "INT2": 2, + "FLOAT": 3, + "HEX": 4, + "TXT": 6, + "TEXT": 6, # Alias +} + +# Data type code to string mapping (for saving csv) +DATA_TYPE_CODE_TO_STR: dict[int, str] = { + 0: "BIT", + 1: "INT", + 2: "INT2", + 3: "FLOAT", + 4: "HEX", + 6: "TEXT", +} + + +class AddressLookupView(AddressNormalizerMixin): + """Address-indexed view over an AddressRecordMap.""" + + def __init__(self, records: AddressRecordMap) -> None: + self._records = records + + def __getitem__(self, address: str) -> AddressRecord: + normalized = self._normalize_address(address) + if normalized is None: + raise KeyError(address) + self._records._ensure_indexes() + if normalized not in self._records._address_index: + raise KeyError(address) + return self._records._address_index[normalized] + + def __contains__(self, address: object) -> bool: + if not isinstance(address, str): + return False + normalized = self._normalize_address(address) + if normalized is None: + return False + self._records._ensure_indexes() + return normalized in self._records._address_index + + def get(self, address: str, default: Any = None) -> AddressRecord | Any: + try: + return self[address] + except KeyError: + return default + + +class TagLookupView: + """Case-insensitive nickname-indexed view over an AddressRecordMap.""" + + def __init__(self, records: AddressRecordMap) -> None: + self._records = records + + def __getitem__(self, nickname: str) -> AddressRecord: + self._records._ensure_indexes() + if self._records._tag_collision_error is not None: + raise ValueError(self._records._tag_collision_error) + lowered = nickname.lower() + if lowered not in self._records._tag_index: + raise KeyError(nickname) + return self._records._tag_index[lowered] + + def __contains__(self, nickname: object) -> bool: + if not isinstance(nickname, str): + return False + self._records._ensure_indexes() + if self._records._tag_collision_error is not None: + raise ValueError(self._records._tag_collision_error) + return nickname.lower() in self._records._tag_index + + def get(self, nickname: str, default: Any = None) -> AddressRecord | Any: + try: + return self[nickname] + except KeyError: + return default + + +class AddressRecordMap(dict[int, AddressRecord]): + """Address record mapping with helper lookup views.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._address_index: dict[str, AddressRecord] = {} + self._tag_index: dict[str, AddressRecord] = {} + self._tag_collision_error: str | None = None + self._index_dirty = True + self.addr = AddressLookupView(self) + self.tag = TagLookupView(self) + + def _mark_dirty(self) -> None: + self._index_dirty = True + + def _ensure_indexes(self) -> None: + if not self._index_dirty: + return + self._rebuild_indexes() + + def _rebuild_indexes(self) -> None: + address_index: dict[str, AddressRecord] = {} + tag_index: dict[str, AddressRecord] = {} + first_seen_tag: dict[str, tuple[str, str]] = {} + collisions: list[tuple[str, str, str, str, str]] = [] + + for record in self.values(): + address_index[record.display_address] = record + nickname = record.nickname.strip() + if nickname == "": + continue + key = nickname.lower() + if key in first_seen_tag: + first_nickname, first_address = first_seen_tag[key] + collisions.append( + (key, first_nickname, first_address, nickname, record.display_address) + ) + continue + first_seen_tag[key] = (nickname, record.display_address) + tag_index[key] = record + + if collisions: + parts = [] + for key, first_nickname, first_address, nickname, address in collisions[:5]: + parts.append( + f"{key!r}: {first_nickname!r}@{first_address} conflicts with {nickname!r}@{address}" + ) + extra = f" (+{len(collisions) - 5} more)" if len(collisions) > 5 else "" + self._tag_collision_error = ( + "Case-insensitive duplicate tag nickname(s) in AddressRecordMap: " + + "; ".join(parts) + + extra + ) + else: + self._tag_collision_error = None + + self._address_index = address_index + self._tag_index = tag_index + self._index_dirty = False + + # -- mutation hooks ----------------------------------------------------- + def __setitem__(self, key: int, value: AddressRecord) -> None: + super().__setitem__(key, value) + self._mark_dirty() + + def __delitem__(self, key: int) -> None: + super().__delitem__(key) + self._mark_dirty() + + def clear(self) -> None: + super().clear() + self._mark_dirty() + + def pop(self, key: int, default: Any = ...): + if default is ...: + value = super().pop(key) + else: + value = super().pop(key, default) + self._mark_dirty() + return value + + def popitem(self) -> tuple[int, AddressRecord]: + value = super().popitem() + self._mark_dirty() + return value + + def setdefault(self, key: int, default: Any = None) -> Any: + value = super().setdefault(key, default) + self._mark_dirty() + return value + + def update(self, *args: Any, **kwargs: Any) -> None: + super().update(*args, **kwargs) + self._mark_dirty() + + +def read_csv(path: str | Path) -> AddressRecordMap: + """Read a user-format CSV file into AddressRecords. + + The user CSV has columns: Address, Data Type, Nickname, Initial Value, + Retentive, Address Comment. + + Args: + path: Path to the CSV file. + + Returns: + AddressRecordMap keyed by addr_key (int) with ``.addr`` and ``.tag`` views. + """ + result = AddressRecordMap() + seen_nicknames: dict[str, tuple[str, str, int]] = {} + + with open(path, newline="", encoding="utf-8") as csvfile: + reader = csv.DictReader(csvfile) + + for row in reader: + addr_str = row.get("Address", "").strip() + if not addr_str: + continue + + try: + mem_type, mdb_address = parse_address(addr_str) + except ValueError: + continue + + if mem_type not in BANKS: + continue + + # Get data type (default based on memory type) + default_data_type = MEMORY_TYPE_TO_DATA_TYPE.get(mem_type, 0) + data_type_str = row.get("Data Type", "").strip().upper() + data_type = DATA_TYPE_STR_TO_CODE.get(data_type_str, default_data_type) + + # Get retentive + default_retentive = DEFAULT_RETENTIVE.get(mem_type, False) + retentive_str = row.get("Retentive", "").strip() + retentive = retentive_str.lower() == "yes" if retentive_str else default_retentive + + # Get other fields + nickname = row.get("Nickname", "").strip() + comment = row.get("Address Comment", "").strip() + initial_value = row.get("Initial Value", "").strip() + + if nickname: + key = nickname.lower() + if key in seen_nicknames: + first_nickname, first_address, first_line = seen_nicknames[key] + raise ValueError( + "Case-insensitive duplicate nickname in CSV at " + f"line {reader.line_num}: {nickname!r} at {addr_str} " + f"conflicts with {first_nickname!r} at {first_address} " + f"(line {first_line})." + ) + seen_nicknames[key] = (nickname, addr_str, reader.line_num) + + addr_key = get_addr_key(mem_type, mdb_address) + + record = AddressRecord( + memory_type=mem_type, + address=mdb_address, + nickname=nickname, + comment=comment, + initial_value=initial_value, + retentive=retentive, + data_type=data_type, + ) + + result[addr_key] = record + + return result + + +def write_csv(path: str | Path, records: Mapping[int, AddressRecord]) -> int: + """Write AddressRecords to a user-format CSV file. + + Only records with content (nickname, comment, non-default initial value + or retentive) are written. Records are sorted by memory type order then + address. + + Args: + path: Path to write the CSV file. + records: Dict mapping addr_key to AddressRecord. + + Returns: + Number of rows written. + """ + # Collect records with content, sorted by memory type order and address + rows_to_write = sorted( + (r for r in records.values() if r.has_content), + key=lambda r: (MEMORY_TYPE_BASES.get(r.memory_type, 0xFFFFFFFF), r.address), + ) + + with open(path, "w", newline="", encoding="utf-8") as csvfile: + # Write header manually (matching CLICK format) + csvfile.write(",".join(CSV_COLUMNS) + "\n") + + def format_quoted(text): + if text is None: + return '""' + escaped_text = str(text).replace('"', '""') + return f'"{escaped_text}"' + + for record in rows_to_write: + data_type_str = DATA_TYPE_CODE_TO_STR.get(record.data_type, "") + + # Format initial value: use "0" for numeric types when empty, "" for TXT + if record.initial_value: + initial_value_str = str(record.initial_value) + elif record.data_type == DataType.TXT: + initial_value_str = "" + else: + initial_value_str = "0" + + line_parts = [ + record.display_address, + data_type_str, + format_quoted(record.nickname), + initial_value_str, + "Yes" if record.retentive else "No", + format_quoted(record.comment), + ] + + csvfile.write(",".join(line_parts) + "\n") + + return len(rows_to_write) diff --git a/src/pyclickplc/server.py b/src/pyclickplc/server.py new file mode 100644 index 0000000..2814c78 --- /dev/null +++ b/src/pyclickplc/server.py @@ -0,0 +1,454 @@ +"""Modbus TCP server simulating an AutomationDirect CLICK PLC. + +Incoming Modbus requests are reverse-mapped to PLC addresses and +routed to a user-supplied DataProvider. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Protocol, runtime_checkable + +from pymodbus.constants import ExcCodes +from pymodbus.datastore import ModbusBaseDeviceContext, ModbusServerContext +from pymodbus.server import ModbusTcpServer + +from .addresses import AddressNormalizerMixin, format_address_display, parse_address +from .banks import BANKS, DataType +from .modbus import ( + MODBUS_MAPPINGS, + modbus_to_plc, + modbus_to_plc_register, + pack_value, + unpack_value, +) +from .validation import assert_runtime_value + +# ============================================================================== +# DataProvider Protocol +# ============================================================================== + +PlcValue = bool | int | float | str + + +@runtime_checkable +class DataProvider(Protocol): + """Backend protocol for PLC value storage.""" + + def read(self, address: str) -> PlcValue: ... + def write(self, address: str, value: PlcValue) -> None: ... + + +# ============================================================================== +# MemoryDataProvider +# ============================================================================== + +# Default values by DataType +_DEFAULTS: dict[DataType, PlcValue] = { + DataType.BIT: False, + DataType.INT: 0, + DataType.INT2: 0, + DataType.FLOAT: 0.0, + DataType.HEX: 0, + DataType.TXT: "\x00", +} + + +class MemoryDataProvider(AddressNormalizerMixin): + """In-memory DataProvider for testing and simple use cases.""" + + def __init__(self) -> None: + self._data: dict[str, PlcValue] = {} + + def _normalize(self, address: str) -> tuple[str, str, int]: + """Normalize address and return (normalized, bank, index).""" + normalized = self._normalize_address_strict(address) + bank, index = parse_address(normalized) + return normalized, bank, index + + def _default(self, bank: str) -> PlcValue: + """Get default value for a bank.""" + data_type = BANKS[bank].data_type + return _DEFAULTS[data_type] + + def read(self, address: str) -> PlcValue: + normalized, bank, _index = self._normalize(address) + return self._data.get(normalized, self._default(bank)) + + def write(self, address: str, value: PlcValue) -> None: + normalized, bank, index = self._normalize(address) + assert_runtime_value(BANKS[bank].data_type, value, bank=bank, index=index) + self._data[normalized] = value + + def get(self, address: str) -> PlcValue: + """Synchronous read convenience.""" + return self.read(address) + + def set(self, address: str, value: PlcValue) -> None: + """Synchronous write convenience.""" + self.write(address, value) + + def bulk_set(self, values: dict[str, PlcValue]) -> None: + """Set multiple values at once.""" + for address, value in values.items(): + self.set(address, value) + + +@dataclass(frozen=True) +class ServerClientInfo: + """Connected client metadata exposed by ClickServer.""" + + client_id: str + peer: str + + +# ============================================================================== +# _ClickDeviceContext +# ============================================================================== + + +def _is_address_writable(bank: str, index: int) -> bool: + """Check if a PLC address is writable via Modbus.""" + mapping = MODBUS_MAPPINGS[bank] + if mapping.writable is not None: + return index in mapping.writable + return mapping.is_writable + + +def _format_plc_address(bank: str, index: int) -> str: + """Format a PLC address string for DataProvider calls.""" + return format_address_display(bank, index) + + +class _ClickDeviceContext(ModbusBaseDeviceContext): + """Custom pymodbus context routing Modbus requests to a DataProvider.""" + + def __init__(self, provider: DataProvider) -> None: + self.provider = provider + super().__init__() + + def reset(self) -> None: + """Required by base class.""" + + def validate(self, func_code: int, address: int, count: int = 1) -> bool: # noqa: ARG002 + """Accept all addresses; errors are handled in get/setValues.""" + return True + + def getValues(self, func_code: int, address: int, count: int = 1) -> list[int] | list[bool]: + """Read values from the DataProvider.""" + try: + if func_code in (1, 2, 5, 15): + return self._get_coils(address, count) + return self._get_registers(address, count) + except Exception: + return [] + + def setValues( + self, func_code: int, address: int, values: list[int] | list[bool] + ) -> ExcCodes | None: + """Write values to the DataProvider.""" + try: + if func_code in (5, 15): + return self._set_coils(address, values) + if func_code == 6: + return self._set_single_register(address, values[0]) + # FC 16 + return self._set_registers(address, values) + except Exception: + return ExcCodes.DEVICE_FAILURE + + # --- Coil helpers --- + + def _get_coils(self, address: int, count: int) -> list[bool]: + result: list[bool] = [] + for i in range(count): + mapped = modbus_to_plc(address + i, is_coil=True) + if mapped is None: + result.append(False) + else: + bank, index = mapped + plc_addr = _format_plc_address(bank, index) + val = self.provider.read(plc_addr) + result.append(bool(val)) + return result + + def _set_coils(self, address: int, values: list[int] | list[bool]) -> ExcCodes | None: + for i, val in enumerate(values): + mapped = modbus_to_plc(address + i, is_coil=True) + if mapped is None: + if len(values) == 1: + return ExcCodes.ILLEGAL_ADDRESS + continue # FC 15: skip gaps silently + bank, index = mapped + # Check writability + if not _is_address_writable(bank, index): + return ExcCodes.ILLEGAL_ADDRESS + plc_addr = _format_plc_address(bank, index) + self.provider.write(plc_addr, bool(val)) + return None + + # --- Register helpers --- + + def _get_registers(self, address: int, count: int) -> list[int]: + result: list[int] = [] + i = 0 + while i < count: + reg_addr = address + i + mapped = modbus_to_plc_register(reg_addr) + if mapped is None: + result.append(0) + i += 1 + continue + + bank, index, reg_position = mapped + mapping = MODBUS_MAPPINGS[bank] + plc_addr = _format_plc_address(bank, index) + + # Handle TXT: 2 chars per register + if bank == "TXT": + # TXT packs 2 addresses per register + # Odd TXT index = low byte, even = high byte + txt_base_index = (index - 1) // 2 * 2 + 1 # Base of pair + low_addr = _format_plc_address("TXT", txt_base_index) + high_addr = _format_plc_address("TXT", txt_base_index + 1) + low_val = self.provider.read(low_addr) + high_val = self.provider.read(high_addr) + low_text = str(low_val) + high_text = str(high_val) + low_byte = ord(low_text) & 0xFF if low_text else 0 + high_byte = ord(high_text) & 0xFF if high_text else 0 + result.append(low_byte | (high_byte << 8)) + i += 1 + continue + + # Read the PLC value + val = self.provider.read(plc_addr) + + # Pack the value + data_type = BANKS[bank].data_type + if data_type == DataType.BIT: + result.append(int(bool(val))) + i += 1 + continue + + regs = pack_value(val, data_type) + + if mapping.width == 1: + result.append(regs[0]) + i += 1 + else: + # Width-2: might only need one register + if reg_position == 0: + # Starting at first register of pair + remaining = count - i + if remaining >= 2: + result.extend(regs) + i += 2 + else: + result.append(regs[0]) + i += 1 + else: + # Starting at second register of pair + result.append(regs[1]) + i += 1 + return result + + def _set_single_register(self, address: int, value: int) -> ExcCodes | None: + """Handle FC 06: write single register.""" + mapped = modbus_to_plc_register(address) + if mapped is None: + return ExcCodes.ILLEGAL_ADDRESS + + bank, index, reg_position = mapped + mapping = MODBUS_MAPPINGS[bank] + + # Check writability + if not _is_address_writable(bank, index): + return ExcCodes.ILLEGAL_ADDRESS + + plc_addr = _format_plc_address(bank, index) + data_type = BANKS[bank].data_type + + # Handle TXT + if bank == "TXT": + low_byte = value & 0xFF + high_byte = (value >> 8) & 0xFF + txt_base_index = (index - 1) // 2 * 2 + 1 + low_addr = _format_plc_address("TXT", txt_base_index) + high_addr = _format_plc_address("TXT", txt_base_index + 1) + self.provider.write(low_addr, chr(low_byte)) + self.provider.write(high_addr, chr(high_byte)) + return None + + if mapping.width == 1: + unpacked = unpack_value([value], data_type) + self.provider.write(plc_addr, unpacked) + else: + # Width-2: read-modify-write + current = self.provider.read(plc_addr) + current_regs = pack_value(current, data_type) + current_regs[reg_position] = value + unpacked = unpack_value(current_regs, data_type) + self.provider.write(plc_addr, unpacked) + return None + + def _set_registers(self, address: int, values: list[int] | list[bool]) -> ExcCodes | None: + """Handle FC 16: write multiple registers.""" + i = 0 + while i < len(values): + reg_addr = address + i + mapped = modbus_to_plc_register(reg_addr) + if mapped is None: + return ExcCodes.ILLEGAL_ADDRESS + + bank, index, reg_position = mapped + mapping = MODBUS_MAPPINGS[bank] + + # Check writability + if not _is_address_writable(bank, index): + return ExcCodes.ILLEGAL_ADDRESS + + plc_addr = _format_plc_address(bank, index) + data_type = BANKS[bank].data_type + + # Handle TXT + if bank == "TXT": + reg_val = int(values[i]) + low_byte = reg_val & 0xFF + high_byte = (reg_val >> 8) & 0xFF + txt_base_index = (index - 1) // 2 * 2 + 1 + low_addr = _format_plc_address("TXT", txt_base_index) + high_addr = _format_plc_address("TXT", txt_base_index + 1) + self.provider.write(low_addr, chr(low_byte)) + self.provider.write(high_addr, chr(high_byte)) + i += 1 + continue + + if mapping.width == 1: + unpacked = unpack_value([int(values[i])], data_type) + self.provider.write(plc_addr, unpacked) + i += 1 + else: + # Width-2 + if reg_position == 0 and i + 1 < len(values): + # Complete pair + regs = [int(values[i]), int(values[i + 1])] + unpacked = unpack_value(regs, data_type) + self.provider.write(plc_addr, unpacked) + i += 2 + else: + # Partial: read-modify-write + current = self.provider.read(plc_addr) + current_regs = pack_value(current, data_type) + current_regs[reg_position] = int(values[i]) + unpacked = unpack_value(current_regs, data_type) + self.provider.write(plc_addr, unpacked) + i += 1 + return None + + +# ============================================================================== +# ClickServer +# ============================================================================== + + +class ClickServer: + """Modbus TCP server simulating a CLICK PLC.""" + + def __init__( + self, + provider: DataProvider, + host: str = "localhost", + port: int = 502, + ) -> None: + """Create a ClickServer. + + Args: + provider: Backend that stores and retrieves PLC values. + host: Interface to bind (default ``"localhost"``). + port: Modbus TCP port (default 502). + """ + self.provider = provider + self.host = host + self.port = port + self._server: ModbusTcpServer | None = None + + @staticmethod + def _format_peer(connection: Any) -> str: + """Best-effort peer formatting from a pymodbus active connection.""" + transport = getattr(connection, "transport", None) + if transport is None: + return "unknown" + peer = transport.get_extra_info("peername") + if isinstance(peer, tuple) and len(peer) >= 2: + return f"{peer[0]}:{peer[1]}" + if peer: + return str(peer) + return "unknown" + + def is_running(self) -> bool: + """Return True when the underlying listener transport is active.""" + return bool(self._server is not None and self._server.is_active()) + + def list_clients(self) -> list[ServerClientInfo]: + """Return the currently connected Modbus TCP clients.""" + if self._server is None: + return [] + clients: list[ServerClientInfo] = [] + for client_id, connection in self._server.active_connections.items(): + clients.append( + ServerClientInfo(client_id=client_id, peer=self._format_peer(connection)) + ) + return clients + + def disconnect_client(self, client_id: str) -> bool: + """Disconnect a single client by pymodbus connection id.""" + if self._server is None: + return False + connection = self._server.active_connections.get(client_id) + if connection is None: + return False + connection.close() + return True + + def disconnect_all_clients(self) -> int: + """Disconnect all connected clients and return how many were closed.""" + if self._server is None: + return 0 + active = list(self._server.active_connections.values()) + for connection in active: + connection.close() + return len(active) + + async def start(self) -> None: + """Start the Modbus TCP server.""" + context = _ClickDeviceContext(self.provider) + server_context = ModbusServerContext(devices=context, single=True) + self._server = ModbusTcpServer( + context=server_context, + address=(self.host, self.port), + ) + await self._server.serve_forever(background=True) + + async def stop(self) -> None: + """Stop the server gracefully.""" + if self._server is not None: + await self._server.shutdown() + self._server = None + + async def serve_forever(self) -> None: + """Start the server and block until stopped.""" + context = _ClickDeviceContext(self.provider) + server_context = ModbusServerContext(devices=context, single=True) + self._server = ModbusTcpServer( + context=server_context, + address=(self.host, self.port), + ) + await self._server.serve_forever(background=False) + + async def __aenter__(self) -> ClickServer: + await self.start() + return self + + async def __aexit__(self, *args: object) -> None: + await self.stop() diff --git a/src/pyclickplc/server_tui.py b/src/pyclickplc/server_tui.py new file mode 100644 index 0000000..3a0c2aa --- /dev/null +++ b/src/pyclickplc/server_tui.py @@ -0,0 +1,107 @@ +"""Minimal interactive terminal UI for ClickServer.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable + +from .server import ClickServer + + +def _print_help(output_fn: Callable[[str], None]) -> None: + output_fn("Commands:") + output_fn(" help Show this command list") + output_fn(" status Show server status") + output_fn(" clients List connected clients") + output_fn(" disconnect Disconnect one client") + output_fn(" disconnect all Disconnect all clients") + output_fn(" shutdown Stop server and exit (aliases: exit, quit)") + + +async def _read_line(prompt: str, input_fn: Callable[[str], str] | None) -> str: + if input_fn is None: + return await asyncio.to_thread(input, prompt) + return input_fn(prompt) + + +async def run_server_tui( + server: ClickServer, + *, + prompt: str = "clickserver> ", + input_fn: Callable[[str], str] | None = None, + output_fn: Callable[[str], None] | None = None, +) -> None: + """Run a basic interactive command loop for a ClickServer.""" + output = output_fn or print + + if not server.is_running(): + await server.start() + + output(f"Serving connection at {server.host}:{server.port}") + output("Type 'help' for commands.") + + cancelled = False + try: + while True: + try: + raw = await _read_line(prompt, input_fn) + except EOFError: + output("EOF received, shutting down server.") + break + except KeyboardInterrupt: + output("Interrupted, shutting down server.") + break + + line = raw.strip() + if not line: + continue + + parts = line.split() + cmd = parts[0].lower() + + if cmd in {"shutdown", "exit", "quit"}: + output("Shutting down server.") + break + + if cmd == "help": + _print_help(output) + continue + + if cmd == "status": + clients = server.list_clients() + output(f"Serving connection at {server.host}:{server.port}") + output(f"Running: {server.is_running()} | Clients: {len(clients)}") + continue + + if cmd == "clients": + clients = server.list_clients() + if not clients: + output("No clients connected.") + else: + for client in clients: + output(f"{client.client_id} {client.peer}") + continue + + if cmd == "disconnect": + if len(parts) != 2: + output("Usage: disconnect ") + continue + target = parts[1] + if target.lower() == "all": + count = server.disconnect_all_clients() + output(f"Disconnected {count} client(s).") + elif server.disconnect_client(target): + output(f"Disconnected client '{target}'.") + else: + output(f"Client '{target}' not found.") + continue + + output(f"Unknown command: {cmd}. Type 'help' for commands.") + except asyncio.CancelledError: + cancelled = True + output("TUI cancelled, shutting down server.") + finally: + await server.stop() + + if cancelled: + raise asyncio.CancelledError diff --git a/src/pyclickplc/validation.py b/src/pyclickplc/validation.py new file mode 100644 index 0000000..182c849 --- /dev/null +++ b/src/pyclickplc/validation.py @@ -0,0 +1,267 @@ +"""Validation functions for PLC address data. + +Validates nicknames, comments, and initial values against CLICK PLC rules. +""" + +from __future__ import annotations + +import math +import struct + +from .banks import ( + DataType, +) + +# ============================================================================== +# Constants +# ============================================================================== + +NICKNAME_MAX_LENGTH = 24 +COMMENT_MAX_LENGTH = 128 + +# Memory types whose nicknames may start with _ (PLC system-generated). +# SC/SD have system-preset names; X gets auto-generated feedback signals +# (e.g. _IO1_Module_Error) when analog input cards are installed. +SYSTEM_NICKNAME_TYPES: frozenset[str] = frozenset({"SC", "SD", "X"}) + +# Characters forbidden in nicknames +# Note: Space is allowed, hyphen (-) and period (.) are forbidden +FORBIDDEN_CHARS: frozenset[str] = frozenset("%\"<>!#$&'()*+-./:;=?@[\\]^`{|}~") + +RESERVED_NICKNAMES: frozenset[str] = frozenset( + { + "log", + "sum", + "sin", + "asin", + "rad", + "cos", + "acos", + "sqrt", + "deg", + "tan", + "atan", + "ln", + "pi", + "mod", + "and", + "or", + "xor", + "lsh", + "rsh", + "lro", + "rro", + } +) + +# Value ranges for validation +INT_MIN = -32768 +INT_MAX = 32767 +INT2_MIN = -2147483648 +INT2_MAX = 2147483647 +FLOAT_MIN = -3.4028235e38 +FLOAT_MAX = 3.4028235e38 + + +# ============================================================================== +# Validation Functions +# ============================================================================== + + +def validate_nickname(nickname: str, *, system_bank: str | None = None) -> tuple[bool, str]: + """Validate nickname format (length, characters, reserved words). + + Does NOT check uniqueness -- that is application-specific. + + Args: + nickname: The nickname to validate + system_bank: Optional system bank context: + - "SC"/"SD": allows PLC system punctuation + - "X": allows only _IO... style system names + - None: standard user nickname rules + + Returns: + Tuple of (is_valid, error_message) - error_message is "" if valid + """ + if nickname == "": + return True, "" # Empty is valid (just means unassigned) + + if len(nickname) > NICKNAME_MAX_LENGTH: + return False, f"Too long ({len(nickname)}/24)" + + def _forbidden_char_error() -> str: + invalid_chars = set(nickname) & FORBIDDEN_CHARS + if invalid_chars: + chars_display = "".join(sorted(invalid_chars)[:3]) + return f"Invalid: {chars_display}" + return "" + + bank = system_bank.upper() if system_bank else None + + if bank in {"SC", "SD"}: + # SC/SD may contain PLC-owned punctuation and leading underscores. + pass + elif bank == "X": + # X system names are PLC auto-generated feedback aliases (_IO...). + if not nickname.startswith("_IO") or len(nickname) == 3 or not nickname[3].isdigit(): + return False, "X system names must start with _IO" + invalid_error = _forbidden_char_error() + if invalid_error: + return False, invalid_error + else: + # Standard user nickname rules. + if nickname.startswith("_"): + return False, "Cannot start with _" + invalid_error = _forbidden_char_error() + if invalid_error: + return False, invalid_error + + if nickname.lower() in RESERVED_NICKNAMES: + return False, "Reserved keyword" + + return True, "" + + +def validate_comment(comment: str) -> tuple[bool, str]: + """Validate comment length. + + Does NOT check block tag uniqueness -- that requires blocks.py context. + + Args: + comment: The comment to validate + + Returns: + Tuple of (is_valid, error_message) - error_message is "" if valid + """ + if comment == "": + return True, "" # Empty is valid + + if len(comment) > COMMENT_MAX_LENGTH: + return False, f"Too long ({len(comment)}/128)" + + return True, "" + + +def validate_initial_value( + initial_value: str, + data_type: int, +) -> tuple[bool, str]: + """Validate an initial value against the data type rules. + + Args: + initial_value: The initial value string to validate + data_type: The DataType number (0=bit, 1=int, 2=int2, 3=float, 4=hex, 6=txt) + + Returns: + Tuple of (is_valid, error_message) - error_message is "" if valid + """ + if initial_value == "": + return True, "" # Empty is valid (means no initial value set) + + if data_type == DataType.BIT: + if initial_value not in ("0", "1"): + return False, "Must be 0 or 1" + return True, "" + + elif data_type == DataType.INT: + try: + val = int(initial_value) + if val < INT_MIN or val > INT_MAX: + return False, f"Range: {INT_MIN} to {INT_MAX}" + return True, "" + except ValueError: + return False, "Must be integer" + + elif data_type == DataType.INT2: + try: + val = int(initial_value) + if val < INT2_MIN or val > INT2_MAX: + return False, f"Range: {INT2_MIN} to {INT2_MAX}" + return True, "" + except ValueError: + return False, "Must be integer" + + elif data_type == DataType.FLOAT: + try: + val = float(initial_value) + if val < FLOAT_MIN or val > FLOAT_MAX: + return False, "Out of float range" + return True, "" + except ValueError: + return False, "Must be number" + + elif data_type == DataType.HEX: + if len(initial_value) > 4: + return False, "Max 4 hex digits" + try: + val = int(initial_value, 16) + if val < 0 or val > 0xFFFF: + return False, "Range: 0000 to FFFF" + return True, "" + except ValueError: + return False, "Must be hex (0-9, A-F)" + + elif data_type == DataType.TXT: + if len(initial_value) != 1: + return False, "Must be single char" + if ord(initial_value) > 127: + return False, "Must be ASCII" + return True, "" + + # Unknown data type + return True, "" + + +def assert_runtime_value( + data_type: DataType, + value: object, + *, + bank: str, + index: int, +) -> None: + """Assert runtime write value validity for a specific PLC address. + + Raises ValueError with a deterministic, actionable message on invalid values. + """ + target = f"{bank}{index}" + + if data_type == DataType.BIT: + if type(value) is not bool: + raise ValueError(f"{target} value must be bool.") + return + + if data_type == DataType.INT: + if type(value) is not int or value < INT_MIN or value > INT_MAX: + raise ValueError(f"{target} value must be int in [{INT_MIN}, {INT_MAX}].") + return + + if data_type == DataType.INT2: + if type(value) is not int or value < INT2_MIN or value > INT2_MAX: + raise ValueError(f"{target} value must be int in [{INT2_MIN}, {INT2_MAX}].") + return + + if data_type == DataType.HEX: + if type(value) is not int or value < 0 or value > 0xFFFF: + raise ValueError(f"{target} must be WORD (0..65535).") + return + + if data_type == DataType.FLOAT: + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise ValueError(f"{target} value must be a finite float32.") + numeric_value = float(value) + if not math.isfinite(numeric_value): + raise ValueError(f"{target} value must be a finite float32.") + try: + struct.pack(" 127: + raise ValueError(f"{target} TXT value must be blank or a single ASCII character.") + return diff --git a/tests/test_addresses.py b/tests/test_addresses.py new file mode 100644 index 0000000..d0892d2 --- /dev/null +++ b/tests/test_addresses.py @@ -0,0 +1,263 @@ +"""Tests for pyclickplc.addresses module.""" + +from dataclasses import FrozenInstanceError, replace +from typing import Any, cast + +import pytest + +from pyclickplc.addresses import ( + AddressRecord, + format_address_display, + get_addr_key, + is_xd_yd_hidden_slot, + is_xd_yd_upper_byte, + normalize_address, + parse_addr_key, + parse_address, + xd_yd_display_to_mdb, + xd_yd_mdb_to_display, +) +from pyclickplc.banks import BANKS, DataType + +# ============================================================================== +# addr_key functions +# ============================================================================== + + +class TestAddrKey: + def test_get_parse_roundtrip_all_types(self): + for mem_type in BANKS: + addr = 1 + key = get_addr_key(mem_type, addr) + parsed_type, parsed_addr = parse_addr_key(key) + assert parsed_type == mem_type + assert parsed_addr == addr + + def test_specific_values(self): + assert get_addr_key("X", 1) == 0x0000001 + assert get_addr_key("DS", 100) == 0x6000064 + assert get_addr_key("TXT", 1) == 0xF000001 + + def test_unknown_type_raises(self): + with pytest.raises(KeyError): + get_addr_key("FAKE", 1) + + +# ============================================================================== +# XD/YD helpers +# ============================================================================== + + +class TestXdYdHelpers: + def test_upper_byte(self): + assert is_xd_yd_upper_byte("XD", 1) is True + assert is_xd_yd_upper_byte("YD", 1) is True + assert is_xd_yd_upper_byte("XD", 0) is False + assert is_xd_yd_upper_byte("XD", 2) is False + assert is_xd_yd_upper_byte("DS", 1) is False + + def test_hidden_slot(self): + assert is_xd_yd_hidden_slot("XD", 3) is True + assert is_xd_yd_hidden_slot("XD", 5) is True + assert is_xd_yd_hidden_slot("XD", 15) is True + assert is_xd_yd_hidden_slot("XD", 0) is False + assert is_xd_yd_hidden_slot("XD", 1) is False + assert is_xd_yd_hidden_slot("XD", 2) is False + assert is_xd_yd_hidden_slot("DS", 3) is False + + def test_mdb_to_display(self): + assert xd_yd_mdb_to_display(0) == 0 + assert xd_yd_mdb_to_display(1) == 0 # XD0u -> display 0 + assert xd_yd_mdb_to_display(2) == 1 + assert xd_yd_mdb_to_display(4) == 2 + assert xd_yd_mdb_to_display(16) == 8 + + def test_display_to_mdb(self): + assert xd_yd_display_to_mdb(0) == 0 + assert xd_yd_display_to_mdb(0, upper_byte=True) == 1 + assert xd_yd_display_to_mdb(1) == 2 + assert xd_yd_display_to_mdb(8) == 16 + + def test_mdb_display_roundtrip(self): + # display -> mdb -> display for 0-8 + for d in range(9): + mdb = xd_yd_display_to_mdb(d) + assert xd_yd_mdb_to_display(mdb) == d + + +# ============================================================================== +# format_address_display +# ============================================================================== + + +class TestFormatAddressDisplay: + def test_x_y_padded(self): + assert format_address_display("X", 1) == "X001" + assert format_address_display("Y", 816) == "Y816" + assert format_address_display("X", 36) == "X036" + + def test_xd_yd_special(self): + assert format_address_display("XD", 0) == "XD0" + assert format_address_display("XD", 1) == "XD0u" + assert format_address_display("XD", 2) == "XD1" + assert format_address_display("XD", 16) == "XD8" + + def test_ds_no_padding(self): + assert format_address_display("DS", 1) == "DS1" + assert format_address_display("DS", 100) == "DS100" + assert format_address_display("DS", 4500) == "DS4500" + + +# ============================================================================== +# parse_address +# ============================================================================== + + +class TestParseAddress: + def test_basic_types(self): + assert parse_address("X001") == ("X", 1) + assert parse_address("DS100") == ("DS", 100) + assert parse_address("C1") == ("C", 1) + + def test_case_insensitive(self): + assert parse_address("x001") == ("X", 1) + assert parse_address("ds100") == ("DS", 100) + + def test_xd_yd_returns_mdb(self): + # parse_address returns MDB address + assert parse_address("XD0") == ("XD", 0) + assert parse_address("XD0U") == ("XD", 1) + assert parse_address("XD0u") == ("XD", 1) + assert parse_address("XD1") == ("XD", 2) + assert parse_address("XD8") == ("XD", 16) + + def test_raises_on_invalid(self): + with pytest.raises(ValueError): + parse_address("") + with pytest.raises(ValueError): + parse_address("FAKE1") + with pytest.raises(ValueError): + parse_address("DS0") # Out of range + with pytest.raises(ValueError): + parse_address("DS4501") # Out of range + with pytest.raises(ValueError): + parse_address("!!!") + with pytest.raises(ValueError): + parse_address("XD1U") # Only XD0 can have U + + def test_raises_on_sparse_gap(self): + with pytest.raises(ValueError): + parse_address("X017") # In gap between slots + + +# ============================================================================== +# normalize_address +# ============================================================================== + + +class TestNormalizeAddress: + def test_roundtrip(self): + assert normalize_address("x1") == "X001" + assert normalize_address("X001") == "X001" + assert normalize_address("ds100") == "DS100" + assert normalize_address("xd0u") == "XD0u" + assert normalize_address("XD0U") == "XD0u" + + def test_invalid_returns_none(self): + assert normalize_address("") is None + assert normalize_address("FAKE1") is None + + +# ============================================================================== +# AddressRecord +# ============================================================================== + + +class TestAddressRecord: + def test_frozen(self): + rec = AddressRecord(memory_type="DS", address=1, data_type=DataType.INT) + with pytest.raises(FrozenInstanceError): + cast(Any, rec).nickname = "test" + + def test_replace(self): + rec = AddressRecord(memory_type="DS", address=1, data_type=DataType.INT) + new_rec = replace(rec, nickname="Updated") + assert new_rec.nickname == "Updated" + assert rec.nickname == "" + + def test_addr_key_matches_standalone(self): + rec = AddressRecord(memory_type="DS", address=100, data_type=DataType.INT) + assert rec.addr_key == get_addr_key("DS", 100) + + def test_display_address_matches_standalone(self): + rec = AddressRecord(memory_type="X", address=1, data_type=DataType.BIT) + assert rec.display_address == format_address_display("X", 1) + assert rec.display_address == "X001" + + def test_data_type_display(self): + rec = AddressRecord(memory_type="DS", address=1, data_type=DataType.INT) + assert rec.data_type_display == "INT" + + def test_has_content_empty(self): + rec = AddressRecord(memory_type="DS", address=1, data_type=DataType.INT, retentive=True) + assert rec.has_content is False # Default retentive for DS is True + + def test_has_content_with_nickname(self): + rec = AddressRecord( + memory_type="DS", + address=1, + data_type=DataType.INT, + nickname="Test", + retentive=True, + ) + assert rec.has_content is True + + def test_has_content_non_default_retentive(self): + rec = AddressRecord( + memory_type="DS", + address=1, + data_type=DataType.INT, + retentive=False, # DS default is True + ) + assert rec.has_content is True + + def test_can_edit_initial_value(self): + editable = AddressRecord(memory_type="DS", address=1, data_type=DataType.INT) + assert editable.can_edit_initial_value is True + + non_editable = AddressRecord(memory_type="SC", address=1, data_type=DataType.BIT) + assert non_editable.can_edit_initial_value is False + + def test_can_edit_retentive(self): + editable = AddressRecord(memory_type="DS", address=1, data_type=DataType.INT) + assert editable.can_edit_retentive is True + + non_editable = AddressRecord(memory_type="XD", address=0, data_type=DataType.HEX) + assert non_editable.can_edit_retentive is False + + def test_used_default_none(self): + rec = AddressRecord(memory_type="DS", address=1, data_type=DataType.INT) + assert rec.used is None + + def test_is_default_initial_value(self): + rec = AddressRecord(memory_type="DS", address=1, data_type=DataType.INT) + assert rec.is_default_initial_value is True + + rec_zero = replace(rec, initial_value="0") + assert rec_zero.is_default_initial_value is True + + rec_nonzero = replace(rec, initial_value="42") + assert rec_nonzero.is_default_initial_value is False + + def test_is_default_retentive(self): + # DS default is True + rec = AddressRecord( + memory_type="DS", + address=1, + data_type=DataType.INT, + retentive=True, + ) + assert rec.is_default_retentive is True + + rec2 = replace(rec, retentive=False) + assert rec2.is_default_retentive is False diff --git a/tests/test_banks.py b/tests/test_banks.py new file mode 100644 index 0000000..cb073d9 --- /dev/null +++ b/tests/test_banks.py @@ -0,0 +1,214 @@ +"""Tests for pyclickplc.banks module.""" + +from dataclasses import FrozenInstanceError +from typing import Any, cast + +import pytest + +from pyclickplc.banks import ( + _INDEX_TO_TYPE, + _SPARSE_RANGES, + BANKS, + BIT_ONLY_TYPES, + DEFAULT_RETENTIVE, + INTERLEAVED_PAIRS, + INTERLEAVED_TYPE_PAIRS, + MEMORY_TYPE_BASES, + MEMORY_TYPE_TO_DATA_TYPE, + NON_EDITABLE_TYPES, + DataType, + is_valid_address, +) + +# ============================================================================== +# DataType +# ============================================================================== + + +class TestDataType: + def test_enum_values(self): + assert DataType.BIT == 0 + assert DataType.INT == 1 + assert DataType.INT2 == 2 + assert DataType.FLOAT == 3 + assert DataType.HEX == 4 + assert DataType.TXT == 6 + + def test_is_int_enum(self): + assert isinstance(DataType.BIT, int) + assert DataType.BIT + 1 == 1 + + def test_all_members(self): + assert len(DataType) == 6 + + +# ============================================================================== +# BankConfig +# ============================================================================== + + +class TestBankConfig: + def test_frozen(self): + bank = BANKS["DS"] + with pytest.raises(FrozenInstanceError): + cast(Any, bank).name = "other" + + def test_all_16_banks_defined(self): + assert len(BANKS) == 16 + + def test_bank_names(self): + expected = { + "X", + "Y", + "C", + "T", + "CT", + "SC", + "DS", + "DD", + "DH", + "DF", + "XD", + "YD", + "TD", + "CTD", + "SD", + "TXT", + } + assert set(BANKS) == expected + + def test_sparse_ranges_on_x_y_only(self): + for name, bank in BANKS.items(): + if name in ("X", "Y"): + assert bank.valid_ranges is not None + assert bank.valid_ranges == _SPARSE_RANGES + else: + assert bank.valid_ranges is None + + def test_sparse_ranges_structure(self): + assert len(_SPARSE_RANGES) == 10 + assert _SPARSE_RANGES[0] == (1, 16) + assert _SPARSE_RANGES[-1] == (801, 816) + + def test_interleaved_pairs(self): + assert BANKS["T"].interleaved_with == "TD" + assert BANKS["TD"].interleaved_with == "T" + assert BANKS["CT"].interleaved_with == "CTD" + assert BANKS["CTD"].interleaved_with == "CT" + + def test_non_interleaved_banks(self): + for name in ("X", "Y", "C", "SC", "DS", "DD", "DH", "DF", "XD", "YD", "SD", "TXT"): + assert BANKS[name].interleaved_with is None + + def test_xd_yd_start_at_zero(self): + assert BANKS["XD"].min_addr == 0 + assert BANKS["YD"].min_addr == 0 + + def test_specific_ranges(self): + assert BANKS["DS"].max_addr == 4500 + assert BANKS["C"].max_addr == 2000 + assert BANKS["T"].max_addr == 500 + assert BANKS["CT"].max_addr == 250 + assert BANKS["DD"].max_addr == 1000 + assert BANKS["TXT"].max_addr == 1000 + + def test_data_types(self): + assert BANKS["X"].data_type == DataType.BIT + assert BANKS["DS"].data_type == DataType.INT + assert BANKS["DD"].data_type == DataType.INT2 + assert BANKS["DF"].data_type == DataType.FLOAT + assert BANKS["DH"].data_type == DataType.HEX + assert BANKS["TXT"].data_type == DataType.TXT + + +# ============================================================================== +# Legacy Dicts +# ============================================================================== + + +class TestLegacyDicts: + def test_memory_type_bases_keys_match_banks(self): + assert set(MEMORY_TYPE_BASES) == set(BANKS) + + def test_memory_type_bases_unique_values(self): + values = list(MEMORY_TYPE_BASES.values()) + assert len(values) == len(set(values)) + + def test_index_to_type_roundtrip(self): + for name, base in MEMORY_TYPE_BASES.items(): + index = base >> 24 + assert _INDEX_TO_TYPE[index] == name + + def test_default_retentive_keys_match_banks(self): + assert set(DEFAULT_RETENTIVE) == set(BANKS) + + def test_interleaved_type_pairs(self): + assert frozenset({"T", "TD"}) in INTERLEAVED_TYPE_PAIRS + assert frozenset({"CT", "CTD"}) in INTERLEAVED_TYPE_PAIRS + assert len(INTERLEAVED_TYPE_PAIRS) == 2 + + def test_interleaved_pairs_bidirectional(self): + assert INTERLEAVED_PAIRS["T"] == "TD" + assert INTERLEAVED_PAIRS["TD"] == "T" + assert INTERLEAVED_PAIRS["CT"] == "CTD" + assert INTERLEAVED_PAIRS["CTD"] == "CT" + + def test_non_editable_types(self): + assert NON_EDITABLE_TYPES == frozenset({"SC", "SD", "XD", "YD"}) + + def test_memory_type_to_data_type(self): + for name, bank in BANKS.items(): + assert MEMORY_TYPE_TO_DATA_TYPE[name] == bank.data_type + + +# ============================================================================== +# Derived Constants +# ============================================================================== + + +class TestDerivedConstants: + def test_bit_only_types(self): + expected = {"X", "Y", "C", "T", "CT", "SC"} + assert BIT_ONLY_TYPES == expected + + +# ============================================================================== +# is_valid_address +# ============================================================================== + + +class TestIsValidAddress: + def test_contiguous_valid(self): + assert is_valid_address("DS", 1) is True + assert is_valid_address("DS", 4500) is True + assert is_valid_address("DS", 2000) is True + + def test_contiguous_invalid(self): + assert is_valid_address("DS", 0) is False + assert is_valid_address("DS", 4501) is False + + def test_sparse_valid(self): + assert is_valid_address("X", 1) is True + assert is_valid_address("X", 16) is True + assert is_valid_address("X", 101) is True + assert is_valid_address("Y", 801) is True + + def test_sparse_invalid_gap(self): + assert is_valid_address("X", 17) is False + assert is_valid_address("X", 20) is False + assert is_valid_address("X", 100) is False + + def test_sparse_boundary(self): + # X036 is the end of slot 2 + assert is_valid_address("X", 36) is True + # X037 is in the gap between slots + assert is_valid_address("X", 37) is False + + def test_xd_range(self): + assert is_valid_address("XD", 0) is True + assert is_valid_address("XD", 16) is True + assert is_valid_address("XD", 8) is True + assert is_valid_address("XD", 17) is False + + def test_unknown_bank(self): + assert is_valid_address("FAKE", 1) is False diff --git a/tests/test_blocks.py b/tests/test_blocks.py new file mode 100644 index 0000000..c80b916 --- /dev/null +++ b/tests/test_blocks.py @@ -0,0 +1,480 @@ +"""Tests for pyclickplc.blocks — block tag parsing and matching.""" + +from dataclasses import dataclass + +from pyclickplc.blocks import ( + BlockRange, + BlockTag, + compute_all_block_ranges, + extract_block_name, + find_block_range_indices, + find_paired_tag_index, + format_block_tag, + get_block_type, + group_udt_block_names, + is_block_tag, + parse_block_tag, + parse_structured_block_name, + strip_block_tag, + validate_block_span, +) + + +class TestParseBlockTag: + """Tests for parse_block_tag().""" + + def test_empty_comment(self): + result = parse_block_tag("") + assert result == BlockTag(None, None, "", None) + + def test_no_tag(self): + result = parse_block_tag("Just a comment") + assert result.name is None + assert result.tag_type is None + assert result.remaining_text == "Just a comment" + + def test_opening_tag(self): + result = parse_block_tag("") + assert result.name == "Motor" + assert result.tag_type == "open" + assert result.remaining_text == "" + assert result.bg_color is None + + def test_opening_tag_with_text_after(self): + result = parse_block_tag("Valve controls") + assert result.name == "Motor" + assert result.tag_type == "open" + assert result.remaining_text == "Valve controls" + + def test_closing_tag(self): + result = parse_block_tag("") + assert result.name == "Motor" + assert result.tag_type == "close" + assert result.remaining_text == "" + + def test_closing_tag_with_text_after(self): + result = parse_block_tag("end of section") + assert result.name == "Motor" + assert result.tag_type == "close" + assert result.remaining_text == "end of section" + + def test_self_closing_tag(self): + result = parse_block_tag("") + assert result.name == "Spare" + assert result.tag_type == "self-closing" + assert result.remaining_text == "" + + def test_self_closing_no_space(self): + result = parse_block_tag("") + assert result.name == "Spare" + assert result.tag_type == "self-closing" + + def test_opening_with_bg_color(self): + result = parse_block_tag('') + assert result.name == "Motor" + assert result.tag_type == "open" + assert result.bg_color == "#FFCDD2" + + def test_opening_with_bg_single_quotes(self): + result = parse_block_tag("") + assert result.name == "Motor" + assert result.tag_type == "open" + assert result.bg_color == "Red" + + def test_self_closing_with_bg(self): + result = parse_block_tag('') + assert result.name == "Spare" + assert result.tag_type == "self-closing" + assert result.bg_color == "Blue" + + def test_closing_tag_no_bg(self): + """Closing tags don't have bg attribute.""" + result = parse_block_tag('') + assert result.tag_type == "close" + + def test_name_with_spaces(self): + result = parse_block_tag("") + assert result.name == "Alm Bits" + assert result.tag_type == "open" + + def test_unclosed_angle_bracket(self): + result = parse_block_tag("") + assert result.name is None + assert result.tag_type is None + + def test_whitespace_only_tag(self): + result = parse_block_tag("< >") + assert result.name is None + assert result.tag_type is None + + def test_opening_tag_after_text(self): + result = parse_block_tag("Leading text") + assert result.name == "Motor" + assert result.tag_type == "open" + assert result.remaining_text == "Leading text" + + def test_closing_tag_after_text(self): + result = parse_block_tag("Leading text ") + assert result.name == "Motor" + assert result.tag_type == "close" + assert result.remaining_text == "Leading text " + + def test_self_closing_tag_after_text(self): + result = parse_block_tag("Leading text ") + assert result.name == "Motor" + assert result.tag_type == "self-closing" + assert result.remaining_text == "Leading text " + + def test_tag_in_middle_of_text(self): + result = parse_block_tag("Before After") + assert result.name == "Motor" + assert result.remaining_text == "Before After" + + def test_mathematical_inequality_not_a_tag(self): + """Angle brackets used for comparisons should not be parsed as tags.""" + text = "The range can be Min < 5 > Max" + result = parse_block_tag(text) + assert result.name is None + assert result.tag_type is None + assert result.remaining_text == text + + +class TestHelperFunctions: + """Tests for is_block_tag, get_block_type, extract_block_name, strip_block_tag.""" + + def test_is_block_tag_open(self): + assert is_block_tag("") is True + + def test_is_block_tag_close(self): + assert is_block_tag("") is True + + def test_is_block_tag_self_closing(self): + assert is_block_tag("") is True + + def test_is_block_tag_not_tag(self): + assert is_block_tag("Just a comment") is False + + def test_is_block_tag_with_surrounding_text(self): + assert is_block_tag("Text then ") is True + assert is_block_tag(" then text") is True + assert is_block_tag("Inequality < 5 > 2") is False + + def test_get_block_type_open(self): + assert get_block_type("") == "open" + + def test_get_block_type_close(self): + assert get_block_type("") == "close" + + def test_get_block_type_self_closing(self): + assert get_block_type("") == "self-closing" + + def test_get_block_type_none(self): + assert get_block_type("comment") is None + + def test_extract_block_name(self): + assert extract_block_name("") == "Motor" + assert extract_block_name("") == "Motor" + assert extract_block_name("") == "Spare" + assert extract_block_name("no tag") is None + + def test_strip_block_tag_open(self): + assert strip_block_tag("Valve info") == "Valve info" + + def test_strip_block_tag_close(self): + assert strip_block_tag("end") == "end" + + def test_strip_block_tag_self_closing(self): + assert strip_block_tag("") == "" + + def test_strip_block_tag_no_tag(self): + assert strip_block_tag("just comment") == "just comment" + + def test_strip_block_tag_empty(self): + assert strip_block_tag("") == "" + + def test_strip_block_tag_middle(self): + assert strip_block_tag("Before After") == "Before After" + + def test_strip_block_tag_with_inequality(self): + text = "Value < 10" + assert strip_block_tag(text) == text + + +class TestOpaqueStructuredNames: + """BlockTag.name should remain an opaque string.""" + + def test_structured_names_round_trip(self): + names = [ + "Base.field", + "Base:named_array(2,3)", + "Base:block(5)", + "Base:block(start=5)", + ] + + for name in names: + open_comment = format_block_tag(name, "open") + close_comment = format_block_tag(name, "close") + self_closing_comment = format_block_tag(name, "self-closing") + + assert parse_block_tag(open_comment).name == name + assert parse_block_tag(close_comment).name == name + assert parse_block_tag(self_closing_comment).name == name + + assert extract_block_name(open_comment) == name + assert extract_block_name(close_comment) == name + assert extract_block_name(self_closing_comment) == name + + +class TestStructuredBlockNameHelpers: + """Tests for opt-in structured block-name parsing helpers.""" + + def test_parse_structured_block_name_udt(self): + parsed = parse_structured_block_name("Alarm.id") + assert parsed.kind == "udt" + assert parsed.base == "Alarm" + assert parsed.field == "id" + + def test_parse_structured_block_name_named_array(self): + parsed = parse_structured_block_name("AlarmPacked:named_array(2,3)") + assert parsed.kind == "named_array" + assert parsed.base == "AlarmPacked" + assert parsed.count == 2 + assert parsed.stride == 3 + + def test_parse_structured_block_name_block_start(self): + parsed = parse_structured_block_name("WordBank:block(start=5)") + assert parsed.kind == "block" + assert parsed.base == "WordBank" + assert parsed.start == 5 + + def test_parse_structured_block_name_plain_fallback(self): + parsed = parse_structured_block_name("Alarm.bad-name") + assert parsed.kind == "plain" + assert parsed.base == "Alarm.bad-name" + + def test_group_udt_block_names(self): + grouped = group_udt_block_names( + [ + "Alarm.id", + "Alarm.On", + "Alarm.id", + "AlarmPacked:named_array(2,3)", + "WordBank:block(5)", + "Pump.state", + ] + ) + assert grouped == { + "Alarm": ("id", "On"), + "Pump": ("state",), + } + + +# Helper dataclass for testing matching functions +@dataclass +class MockRow: + comment: str + memory_type: str | None = None + + +class TestFindPairedTagIndex: + """Tests for find_paired_tag_index().""" + + def test_simple_open_close(self): + rows = [ + MockRow(""), + MockRow("middle"), + MockRow(""), + ] + assert find_paired_tag_index(rows, 0) == 2 + assert find_paired_tag_index(rows, 2) == 0 + + def test_self_closing_no_pair(self): + rows = [MockRow("")] + assert find_paired_tag_index(rows, 0) is None + + def test_nested_blocks_same_name(self): + rows = [ + MockRow(""), # 0 -> 5 + MockRow(""), # 1 -> 3 + MockRow("inner"), + MockRow(""), # 3 <- 1 + MockRow("outer"), + MockRow(""), # 5 <- 0 + ] + assert find_paired_tag_index(rows, 0) == 5 + assert find_paired_tag_index(rows, 1) == 3 + assert find_paired_tag_index(rows, 3) == 1 + assert find_paired_tag_index(rows, 5) == 0 + + def test_unmatched_open(self): + rows = [MockRow(""), MockRow("no close")] + assert find_paired_tag_index(rows, 0) is None + + def test_unmatched_close(self): + rows = [MockRow("no open"), MockRow("")] + assert find_paired_tag_index(rows, 1) is None + + def test_memory_type_filtering(self): + rows = [ + MockRow("", "T"), + MockRow("T data", "T"), + MockRow("", "TD"), + MockRow("TD data", "TD"), + MockRow("", "TD"), + MockRow("", "T"), + ] + assert find_paired_tag_index(rows, 0) == 5 + assert find_paired_tag_index(rows, 2) == 4 + + +class TestFindBlockRangeIndices: + """Tests for find_block_range_indices().""" + + def test_open_tag_range(self): + rows = [ + MockRow(""), + MockRow("data"), + MockRow(""), + ] + assert find_block_range_indices(rows, 0) == (0, 2) + + def test_close_tag_range(self): + rows = [ + MockRow(""), + MockRow("data"), + MockRow(""), + ] + assert find_block_range_indices(rows, 2) == (0, 2) + + def test_self_closing_range(self): + rows = [MockRow("")] + assert find_block_range_indices(rows, 0) == (0, 0) + + def test_unmatched_open_singular(self): + rows = [MockRow(""), MockRow("no close")] + assert find_block_range_indices(rows, 0) == (0, 0) + + def test_no_tag(self): + rows = [MockRow("just comment")] + assert find_block_range_indices(rows, 0) is None + + +class TestComputeAllBlockRanges: + """Tests for compute_all_block_ranges().""" + + def test_empty_rows(self): + assert compute_all_block_ranges([]) == [] + + def test_no_blocks(self): + rows = [MockRow("comment1"), MockRow("comment2")] + assert compute_all_block_ranges(rows) == [] + + def test_single_block(self): + rows = [ + MockRow(""), + MockRow("data"), + MockRow(""), + ] + ranges = compute_all_block_ranges(rows) + assert len(ranges) == 1 + assert ranges[0].start_idx == 0 + assert ranges[0].end_idx == 2 + assert ranges[0].name == "Motor" + + def test_self_closing_block(self): + rows = [MockRow("")] + ranges = compute_all_block_ranges(rows) + assert len(ranges) == 1 + assert ranges[0].start_idx == 0 + assert ranges[0].end_idx == 0 + + def test_multiple_blocks(self): + rows = [ + MockRow(""), + MockRow(""), + MockRow(""), + MockRow(""), + ] + ranges = compute_all_block_ranges(rows) + assert len(ranges) == 2 + assert ranges[0].name == "A" + assert ranges[1].name == "B" + + def test_nested_blocks(self): + rows = [ + MockRow(""), + MockRow(""), + MockRow(""), + MockRow(""), + ] + ranges = compute_all_block_ranges(rows) + assert len(ranges) == 2 + assert ranges[0] == BlockRange(0, 3, "Outer", None, None) + assert ranges[1] == BlockRange(1, 2, "Inner", None, None) + + def test_unclosed_tag_becomes_singular(self): + rows = [MockRow(""), MockRow("no close")] + ranges = compute_all_block_ranges(rows) + assert len(ranges) == 1 + assert ranges[0].start_idx == 0 + assert ranges[0].end_idx == 0 + + def test_bg_color_preserved(self): + rows = [MockRow(''), MockRow("")] + ranges = compute_all_block_ranges(rows) + assert ranges[0].bg_color == "Red" + + def test_memory_type_preserved(self): + rows = [MockRow("", "T"), MockRow("", "T")] + ranges = compute_all_block_ranges(rows) + assert ranges[0].memory_type == "T" + + +class TestValidateBlockSpan: + """Tests for validate_block_span().""" + + @dataclass + class FakeAddressRow: + memory_type: str + + def test_empty_rows(self): + is_valid, error = validate_block_span([]) + assert is_valid is True + assert error is None + + def test_single_type(self): + rows = [self.FakeAddressRow("DS"), self.FakeAddressRow("DS")] + is_valid, error = validate_block_span(rows) + assert is_valid is True + + def test_paired_types_t_td(self): + rows = [self.FakeAddressRow("T"), self.FakeAddressRow("TD")] + is_valid, error = validate_block_span(rows) + assert is_valid is True + + def test_paired_types_ct_ctd(self): + rows = [self.FakeAddressRow("CT"), self.FakeAddressRow("CTD")] + is_valid, error = validate_block_span(rows) + assert is_valid is True + + def test_incompatible_types(self): + rows = [self.FakeAddressRow("DS"), self.FakeAddressRow("DD")] + is_valid, error = validate_block_span(rows) + assert is_valid is False + assert error is not None + assert "DS" in error + assert "DD" in error + + def test_three_types_invalid(self): + rows = [ + self.FakeAddressRow("T"), + self.FakeAddressRow("TD"), + self.FakeAddressRow("DS"), + ] + is_valid, error = validate_block_span(rows) + assert is_valid is False diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..301f515 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,1032 @@ +"""Tests for pyclickplc.client — ClickClient, AddressAccessor, etc. + +Uses mocked transport (patching internal _read/_write methods). +""" + +from __future__ import annotations + +from typing import Any, cast +from unittest.mock import AsyncMock + +import pytest + +from pyclickplc.addresses import AddressRecord +from pyclickplc.banks import DataType +from pyclickplc.client import ( + AddressAccessor, + AddressInterface, + ClickClient, + DisplayAddressAccessor, + FixedAddressAccessor, + ModbusResponse, + TagInterface, +) +from pyclickplc.modbus import MODBUS_MAPPINGS, pack_value + +# ============================================================================== +# Helpers +# ============================================================================== + + +def _make_plc() -> ClickClient: + """Create a ClickClient without connecting.""" + plc = ClickClient("localhost", 5020) + # Mock internal transport methods + _set_read_coils(plc, [False]) + _set_write_coils(plc) + _set_read_registers(plc, [0]) + _set_write_registers(plc) + return plc + + +def _set_read_coils(plc: ClickClient, return_value: list[bool]) -> AsyncMock: + mock = AsyncMock(return_value=return_value) + object.__setattr__(plc, "_read_coils", mock) + return mock + + +def _set_write_coils(plc: ClickClient) -> AsyncMock: + mock = AsyncMock() + object.__setattr__(plc, "_write_coils", mock) + return mock + + +def _set_read_registers(plc: ClickClient, return_value: list[int]) -> AsyncMock: + mock = AsyncMock(return_value=return_value) + object.__setattr__(plc, "_read_registers", mock) + return mock + + +def _set_write_registers(plc: ClickClient) -> AsyncMock: + mock = AsyncMock() + object.__setattr__(plc, "_write_registers", mock) + return mock + + +def _get_write_coils_mock(plc: ClickClient) -> AsyncMock: + return cast(AsyncMock, plc._write_coils) + + +def _get_write_registers_mock(plc: ClickClient) -> AsyncMock: + return cast(AsyncMock, plc._write_registers) + + +def _as_float(value: object) -> float: + assert isinstance(value, (int, float)) + return float(value) + + +# ============================================================================== +# ClickClient construction and __getattr__ +# ============================================================================== + + +class TestClickClient: + @pytest.mark.asyncio + async def test_construction(self): + plc = ClickClient("192.168.1.100") + assert plc._client.comm_params.host == "192.168.1.100" + assert plc._client.comm_params.port == 502 + assert plc._client.comm_params.reconnect_delay == 0.0 + assert plc._client.comm_params.reconnect_delay_max == 0.0 + assert plc.addr is not None + assert plc.tag is not None + assert plc.tags == {} + + @pytest.mark.asyncio + async def test_construction_with_port(self): + plc = ClickClient("192.168.1.100", 5020) + assert plc._client.comm_params.host == "192.168.1.100" + assert plc._client.comm_params.port == 5020 + + @pytest.mark.asyncio + async def test_construction_legacy_host_port_string(self): + plc = ClickClient("192.168.1.100:5020") + assert plc._client.comm_params.host == "192.168.1.100" + assert plc._client.comm_params.port == 5020 + + @pytest.mark.asyncio + async def test_construction_with_device_id(self): + plc = ClickClient("192.168.1.100", 5020, device_id=7) + assert plc._device_id == 7 + + @pytest.mark.asyncio + async def test_construction_with_reconnect_settings(self): + plc = ClickClient("192.168.1.100", reconnect_delay=0.5, reconnect_delay_max=2.0) + assert plc._client.comm_params.reconnect_delay == 0.5 + assert plc._client.comm_params.reconnect_delay_max == 2.0 + + @pytest.mark.asyncio + async def test_construction_with_programmatic_tags(self): + plc = ClickClient( + "192.168.1.100", + tags={ + "ignored": AddressRecord(memory_type="DF", address=1, nickname="Temp"), + "other": AddressRecord(memory_type="C", address=1, nickname="Valve"), + }, + ) + assert set(plc.tags.keys()) == {"Temp", "Valve"} + assert plc.tags["Temp"]["address"] == "DF1" + + @pytest.mark.asyncio + async def test_construction_with_programmatic_tags_skips_empty_nickname(self): + plc = ClickClient( + "192.168.1.100", + tags={ + "first": AddressRecord(memory_type="DF", address=1, nickname=""), + "second": AddressRecord(memory_type="C", address=1, nickname="Valve"), + }, + ) + assert set(plc.tags.keys()) == {"Valve"} + + @pytest.mark.asyncio + async def test_construction_with_programmatic_tags_rejects_case_collisions(self): + with pytest.raises(ValueError, match="duplicate nickname"): + ClickClient( + "192.168.1.100", + tags={ + "a": AddressRecord(memory_type="DF", address=1, nickname="Pump"), + "b": AddressRecord(memory_type="DF", address=2, nickname="pump"), + }, + ) + + @pytest.mark.asyncio + async def test_getattr_df(self): + plc = _make_plc() + accessor = plc.df + assert isinstance(accessor, AddressAccessor) + + @pytest.mark.asyncio + async def test_getattr_xd_is_display_indexed_accessor(self): + plc = _make_plc() + assert isinstance(plc.xd, DisplayAddressAccessor) + + @pytest.mark.asyncio + async def test_getattr_yd_is_display_indexed_accessor(self): + plc = _make_plc() + assert isinstance(plc.yd, DisplayAddressAccessor) + + @pytest.mark.asyncio + async def test_upper_byte_aliases_are_available(self): + plc = _make_plc() + assert isinstance(plc.xd0u, FixedAddressAccessor) + assert isinstance(plc.yd0u, FixedAddressAccessor) + assert plc.XD0U is plc.xd0u + assert plc.YD0U is plc.yd0u + + @pytest.mark.asyncio + async def test_getattr_case_insensitive(self): + plc = _make_plc() + accessor1 = plc.df + accessor2 = plc.DF + assert accessor1 is accessor2 + + @pytest.mark.asyncio + async def test_getattr_cached(self): + plc = _make_plc() + a1 = plc.ds + a2 = plc.ds + assert a1 is a2 + + @pytest.mark.asyncio + async def test_getattr_underscore_raises(self): + plc = _make_plc() + with pytest.raises(AttributeError): + _ = cast(Any, plc)._private + + @pytest.mark.asyncio + async def test_getattr_unknown_raises(self): + plc = _make_plc() + with pytest.raises(AttributeError, match="not a supported"): + _ = cast(Any, plc).invalid_bank + + @pytest.mark.asyncio + async def test_addr_is_address_interface(self): + plc = _make_plc() + assert isinstance(plc.addr, AddressInterface) + + @pytest.mark.asyncio + async def test_tag_is_tag_interface(self): + plc = _make_plc() + assert isinstance(plc.tag, TagInterface) + + +# ============================================================================== +# AddressAccessor — repr +# ============================================================================== + + +class TestAddressAccessorRepr: + @pytest.mark.asyncio + async def test_repr_df(self): + plc = _make_plc() + assert repr(plc.df) == "" + + @pytest.mark.asyncio + async def test_repr_ds(self): + plc = _make_plc() + assert repr(plc.ds) == "" + + @pytest.mark.asyncio + async def test_repr_x(self): + plc = _make_plc() + assert repr(plc.x) == "" + + @pytest.mark.asyncio + async def test_repr_xd_display_accessor(self): + plc = _make_plc() + assert repr(plc.xd) == "" + + @pytest.mark.asyncio + async def test_repr_xd0u_fixed_accessor(self): + plc = _make_plc() + assert repr(plc.xd0u) == "" + + +# ============================================================================== +# AddressAccessor — read single +# ============================================================================== + + +class TestAddressAccessorReadSingle: + @pytest.mark.asyncio + async def test_read_float(self): + plc = _make_plc() + regs = pack_value(3.14, DataType.FLOAT) + _set_read_registers(plc, regs) + result = await plc.df.read(1) + assert isinstance(result, ModbusResponse) + import math + + assert math.isclose(result["DF1"], 3.14, rel_tol=1e-6) + + @pytest.mark.asyncio + async def test_read_int16(self): + plc = _make_plc() + _set_read_registers(plc, [42]) + result = await plc.ds.read(1) + assert result == {"DS1": 42} + + @pytest.mark.asyncio + async def test_read_int32(self): + plc = _make_plc() + regs = pack_value(100000, DataType.INT2) + _set_read_registers(plc, regs) + result = await plc.dd.read(1) + assert result == {"DD1": 100000} + + @pytest.mark.asyncio + async def test_read_unsigned(self): + plc = _make_plc() + _set_read_registers(plc, [0xABCD]) + result = await plc.dh.read(1) + assert result == {"DH1": 0xABCD} + + @pytest.mark.asyncio + async def test_read_bool(self): + plc = _make_plc() + _set_read_coils(plc, [True]) + result = await plc.c.read(1) + assert result == {"C1": True} + + @pytest.mark.asyncio + async def test_read_sparse_bool(self): + plc = _make_plc() + _set_read_coils(plc, [True]) + result = await plc.x.read(101) + assert result == {"X101": True} + + @pytest.mark.asyncio + async def test_read_txt(self): + plc = _make_plc() + # TXT1 is low byte of register + _set_read_registers(plc, [ord("A") | (ord("B") << 8)]) + result = await plc.txt.read(1) + assert result == {"TXT1": "A"} + + @pytest.mark.asyncio + async def test_read_txt_even(self): + plc = _make_plc() + _set_read_registers(plc, [ord("A") | (ord("B") << 8)]) + result = await plc.txt.read(2) + assert result == {"TXT2": "B"} + + +# ============================================================================== +# AddressAccessor — read range +# ============================================================================== + + +class TestAddressAccessorReadRange: + @pytest.mark.asyncio + async def test_read_df_range(self): + plc = _make_plc() + r1 = pack_value(1.0, DataType.FLOAT) + r2 = pack_value(2.0, DataType.FLOAT) + _set_read_registers(plc, r1 + r2) + result = await plc.df.read(1, 2) + assert isinstance(result, ModbusResponse) + assert len(result) == 2 + import math + + assert math.isclose(result["DF1"], 1.0, rel_tol=1e-6) + assert math.isclose(result["DF2"], 2.0, rel_tol=1e-6) + + @pytest.mark.asyncio + async def test_read_c_range(self): + plc = _make_plc() + _set_read_coils(plc, [True, False, True]) + result = await plc.c.read(1, 3) + assert result == {"C1": True, "C2": False, "C3": True} + + @pytest.mark.asyncio + async def test_read_end_le_start_raises(self): + plc = _make_plc() + with pytest.raises(ValueError, match="greater than start"): + await plc.df.read(10, 5) + + +# ============================================================================== +# DisplayAddressAccessor — XD/YD ergonomics +# ============================================================================== + + +class TestDisplayAddressAccessor: + @pytest.mark.asyncio + async def test_read_xd_display_single(self): + plc = _make_plc() + + async def fake_read_registers(address: int, count: int, bank: str) -> list[int]: + del bank + return [address + i for i in range(count)] + + object.__setattr__(plc, "_read_registers", AsyncMock(side_effect=fake_read_registers)) + result = await plc.xd.read(3) + assert result == {"XD3": 57350} + + @pytest.mark.asyncio + async def test_read_xd_display_range_has_no_hidden_slots(self): + plc = _make_plc() + + async def fake_read_registers(address: int, count: int, bank: str) -> list[int]: + del bank + return [address + i for i in range(count)] + + object.__setattr__(plc, "_read_registers", AsyncMock(side_effect=fake_read_registers)) + result = await plc.xd.read(0, 4) + assert list(result.keys()) == ["XD0", "XD1", "XD2", "XD3", "XD4"] + assert list(result.values()) == [57344, 57346, 57348, 57350, 57352] + + @pytest.mark.asyncio + async def test_write_yd_display_single(self): + plc = _make_plc() + await plc.yd.write(3, 0xABCD) + _get_write_registers_mock(plc).assert_called_once_with(57862, [0xABCD]) + + @pytest.mark.asyncio + async def test_write_yd_display_list(self): + plc = _make_plc() + await plc.yd.write(0, [1, 2, 3]) + assert _get_write_registers_mock(plc).call_count == 3 + assert _get_write_registers_mock(plc).call_args_list[0].args == (57856, [1]) + assert _get_write_registers_mock(plc).call_args_list[1].args == (57858, [2]) + assert _get_write_registers_mock(plc).call_args_list[2].args == (57860, [3]) + + @pytest.mark.asyncio + async def test_write_xd_display_not_writable(self): + plc = _make_plc() + with pytest.raises(ValueError, match="not writable"): + await plc.xd.write(1, 0x1234) + + @pytest.mark.asyncio + async def test_read_xd_out_of_range_raises(self): + plc = _make_plc() + with pytest.raises(ValueError): + await plc.xd.read(9) + + +# ============================================================================== +# FixedAddressAccessor — XD0u/YD0u +# ============================================================================== + + +class TestFixedAddressAccessor: + @pytest.mark.asyncio + async def test_read_xd0u(self): + plc = _make_plc() + _set_read_registers(plc, [0x1234]) + result = await plc.xd0u.read() + assert result == {"XD0u": 0x1234} + + @pytest.mark.asyncio + async def test_write_xd0u_not_writable(self): + plc = _make_plc() + with pytest.raises(ValueError, match="not writable"): + await plc.xd0u.write(0x1234) + + @pytest.mark.asyncio + async def test_write_yd0u(self): + plc = _make_plc() + await plc.yd0u.write(0x1234) + _get_write_registers_mock(plc).assert_called_once_with(57857, [0x1234]) + + +# ============================================================================== +# AddressAccessor — write +# ============================================================================== + + +class TestAddressAccessorWrite: + @pytest.mark.asyncio + async def test_write_float(self): + plc = _make_plc() + await plc.df.write(1, 3.14) + _get_write_registers_mock(plc).assert_called_once() + + @pytest.mark.asyncio + async def test_write_int16(self): + plc = _make_plc() + await plc.ds.write(1, 42) + _get_write_registers_mock(plc).assert_called_once() + + @pytest.mark.asyncio + async def test_write_bool(self): + plc = _make_plc() + await plc.c.write(1, True) + _get_write_coils_mock(plc).assert_called_once() + + @pytest.mark.asyncio + async def test_write_list(self): + plc = _make_plc() + await plc.df.write(1, [1.0, 2.0, 3.0]) + _get_write_registers_mock(plc).assert_called_once() + + @pytest.mark.asyncio + async def test_write_wrong_type_raises(self): + plc = _make_plc() + with pytest.raises(ValueError, match="DF1 value must be a finite float32"): + await plc.df.write(1, "string") + + @pytest.mark.asyncio + async def test_write_float_to_int_raises(self): + plc = _make_plc() + with pytest.raises(ValueError, match="DS1 value must be int"): + await plc.ds.write(1, 3.14) + + @pytest.mark.asyncio + async def test_write_int16_overflow_raises(self): + plc = _make_plc() + with pytest.raises(ValueError, match="DS1 value must be int"): + await plc.ds.write(1, 32768) + + @pytest.mark.asyncio + async def test_write_int32_overflow_raises(self): + plc = _make_plc() + with pytest.raises(ValueError, match="DD1 value must be int"): + await plc.dd.write(1, 2147483648) + + @pytest.mark.asyncio + async def test_write_word_underflow_raises(self): + plc = _make_plc() + with pytest.raises(ValueError, match="DH1 must be WORD"): + await plc.dh.write(1, -1) + + @pytest.mark.asyncio + async def test_write_bool_for_numeric_raises(self): + plc = _make_plc() + with pytest.raises(ValueError, match="DS1 value must be int"): + await plc.ds.write(1, True) + + @pytest.mark.asyncio + async def test_write_nan_float_raises(self): + plc = _make_plc() + with pytest.raises(ValueError, match="DF1 value must be a finite float32"): + await plc.df.write(1, float("nan")) + + @pytest.mark.asyncio + async def test_write_inf_float_raises(self): + plc = _make_plc() + with pytest.raises(ValueError, match="DF1 value must be a finite float32"): + await plc.df.write(1, float("inf")) + + @pytest.mark.asyncio + async def test_write_txt_too_long_raises(self): + plc = _make_plc() + with pytest.raises( + ValueError, match="TXT1 TXT value must be blank or a single ASCII character" + ): + await plc.txt.write(1, "AB") + + @pytest.mark.asyncio + async def test_write_txt_non_ascii_raises(self): + plc = _make_plc() + with pytest.raises( + ValueError, match="TXT1 TXT value must be blank or a single ASCII character" + ): + await plc.txt.write(1, "\u00e9") + + @pytest.mark.asyncio + async def test_write_not_writable_x(self): + plc = _make_plc() + with pytest.raises(ValueError, match="not writable"): + await plc.x.write(1, True) + + @pytest.mark.asyncio + async def test_write_not_writable_sc(self): + plc = _make_plc() + with pytest.raises(ValueError, match="not writable"): + await plc.sc.write(1, True) + + @pytest.mark.asyncio + async def test_write_writable_sc53(self): + plc = _make_plc() + await plc.sc.write(53, True) + _get_write_coils_mock(plc).assert_called_once() + + @pytest.mark.asyncio + async def test_write_not_writable_sd(self): + plc = _make_plc() + with pytest.raises(ValueError, match="not writable"): + await plc.sd.write(1, 42) + + @pytest.mark.asyncio + async def test_write_writable_sd29(self): + plc = _make_plc() + await plc.sd.write(29, 100) + _get_write_registers_mock(plc).assert_called_once() + + +# ============================================================================== +# AddressAccessor — validation errors +# ============================================================================== + + +class TestAddressAccessorValidation: + @pytest.mark.asyncio + async def test_out_of_range_low(self): + plc = _make_plc() + with pytest.raises(ValueError): + await plc.df.read(0) + + @pytest.mark.asyncio + async def test_out_of_range_high(self): + plc = _make_plc() + with pytest.raises(ValueError): + await plc.df.read(501) + + @pytest.mark.asyncio + async def test_sparse_gap(self): + plc = _make_plc() + with pytest.raises(ValueError): + await plc.x.read(17) + + @pytest.mark.asyncio + async def test_sparse_gap_37(self): + plc = _make_plc() + with pytest.raises(ValueError): + await plc.x.read(37) + + @pytest.mark.asyncio + async def test_read_max_df(self): + """Reading at max address should work.""" + plc = _make_plc() + regs = pack_value(0.0, DataType.FLOAT) + _set_read_registers(plc, regs) + result = await plc.df.read(500) + assert result == {"DF500": 0.0} + + +# ============================================================================== +# AddressInterface +# ============================================================================== + + +class TestAddressInterface: + @pytest.mark.asyncio + async def test_read_single(self): + plc = _make_plc() + regs = pack_value(3.14, DataType.FLOAT) + _set_read_registers(plc, regs) + result = await plc.addr.read("df1") + assert isinstance(result, ModbusResponse) + import math + + assert math.isclose(_as_float(result["DF1"]), 3.14, rel_tol=1e-6) + + @pytest.mark.asyncio + async def test_read_range(self): + plc = _make_plc() + r1 = pack_value(1.0, DataType.FLOAT) + r2 = pack_value(2.0, DataType.FLOAT) + _set_read_registers(plc, r1 + r2) + result = await plc.addr.read("df1-df2") + assert isinstance(result, ModbusResponse) + assert len(result) == 2 + + @pytest.mark.asyncio + async def test_read_case_insensitive(self): + plc = _make_plc() + regs = pack_value(0.0, DataType.FLOAT) + _set_read_registers(plc, regs) + result = await plc.addr.read("DF1") + assert result == {"DF1": 0.0} + + @pytest.mark.asyncio + async def test_inter_bank_range_raises(self): + plc = _make_plc() + with pytest.raises(ValueError, match="Inter-bank"): + await plc.addr.read("df1-dd10") + + @pytest.mark.asyncio + async def test_end_le_start_raises(self): + plc = _make_plc() + with pytest.raises(ValueError, match="greater than start"): + await plc.addr.read("df10-df5") + + @pytest.mark.asyncio + async def test_xd_range_is_display_step(self): + plc = _make_plc() + + async def fake_read_registers(address: int, count: int, bank: str) -> list[int]: + del bank + return [address + i for i in range(count)] + + object.__setattr__(plc, "_read_registers", AsyncMock(side_effect=fake_read_registers)) + result = await plc.addr.read("XD0-XD4") + assert list(result.keys()) == ["XD0", "XD1", "XD2", "XD3", "XD4"] + assert list(result.values()) == [57344, 57346, 57348, 57350, 57352] + + @pytest.mark.asyncio + async def test_yd_range_is_display_step(self): + plc = _make_plc() + + async def fake_read_registers(address: int, count: int, bank: str) -> list[int]: + del bank + return [address + i for i in range(count)] + + object.__setattr__(plc, "_read_registers", AsyncMock(side_effect=fake_read_registers)) + result = await plc.addr.read("YD0-YD2") + assert list(result.keys()) == ["YD0", "YD1", "YD2"] + assert list(result.values()) == [57856, 57858, 57860] + + @pytest.mark.asyncio + async def test_xd_range_rejects_upper_byte_start(self): + plc = _make_plc() + with pytest.raises(ValueError, match="upper-byte"): + await plc.addr.read("XD0u-XD1") + + @pytest.mark.asyncio + async def test_xd_range_rejects_upper_byte_end(self): + plc = _make_plc() + with pytest.raises(ValueError, match="upper-byte"): + await plc.addr.read("XD0-XD0u") + + @pytest.mark.asyncio + async def test_single_xd0u_read_still_works(self): + plc = _make_plc() + _set_read_registers(plc, [99]) + result = await plc.addr.read("XD0u") + assert result == {"XD0u": 99} + + @pytest.mark.asyncio + async def test_invalid_address_raises(self): + plc = _make_plc() + with pytest.raises(ValueError): + await plc.addr.read("invalid1") + + @pytest.mark.asyncio + async def test_write_single(self): + plc = _make_plc() + await plc.addr.write("df1", 3.14) + _get_write_registers_mock(plc).assert_called_once() + + @pytest.mark.asyncio + async def test_write_list(self): + plc = _make_plc() + await plc.addr.write("df1", [1.0, 2.0]) + _get_write_registers_mock(plc).assert_called_once() + + +# ============================================================================== +# TagInterface (without actual CSV file) +# ============================================================================== + + +class TestTagInterface: + def _plc_with_tags(self) -> ClickClient: + plc = _make_plc() + plc.tags = { + "Temp": {"address": "DF1", "type": "FLOAT", "comment": "Temperature"}, + "Valve": {"address": "C1", "type": "BIT", "comment": "Valve open"}, + } + return plc + + @pytest.mark.asyncio + async def test_read_single_tag(self): + plc = self._plc_with_tags() + regs = pack_value(25.0, DataType.FLOAT) + _set_read_registers(plc, regs) + value = await plc.tag.read("Temp") + import math + + assert math.isclose(_as_float(value), 25.0, rel_tol=1e-6) + + @pytest.mark.asyncio + async def test_read_missing_tag_raises(self): + plc = self._plc_with_tags() + with pytest.raises(KeyError, match="not found"): + await plc.tag.read("NonExistent") + + @pytest.mark.asyncio + async def test_read_tag_case_insensitive(self): + plc = self._plc_with_tags() + regs = pack_value(25.0, DataType.FLOAT) + _set_read_registers(plc, regs) + value = await plc.tag.read("temp") + import math + + assert math.isclose(_as_float(value), 25.0, rel_tol=1e-6) + + @pytest.mark.asyncio + async def test_read_all_tags(self): + plc = self._plc_with_tags() + regs = pack_value(25.0, DataType.FLOAT) + _set_read_registers(plc, regs) + _set_read_coils(plc, [True]) + result = await plc.tag.read_all() + assert isinstance(result, dict) + assert "Temp" in result + assert "Valve" in result + + @pytest.mark.asyncio + async def test_read_all_no_tags_raises(self): + plc = _make_plc() + with pytest.raises(ValueError, match="No tags loaded"): + await plc.tag.read_all() + + @pytest.mark.asyncio + async def test_read_all_excludes_system_by_default(self): + plc = self._plc_with_tags() + plc.tags["SysFlag"] = {"address": "SC1", "type": "BIT", "comment": "System"} + plc.tags["SysData"] = {"address": "SD1", "type": "INT", "comment": "System"} + regs = pack_value(25.0, DataType.FLOAT) + _set_read_registers(plc, regs) + _set_read_coils(plc, [True]) + result = await plc.tag.read_all() + assert "Temp" in result + assert "Valve" in result + assert "SysFlag" not in result + assert "SysData" not in result + + @pytest.mark.asyncio + async def test_read_all_includes_system_when_requested(self): + plc = self._plc_with_tags() + plc.tags["SysFlag"] = {"address": "SC1", "type": "BIT", "comment": "System"} + regs = pack_value(25.0, DataType.FLOAT) + _set_read_registers(plc, regs) + _set_read_coils(plc, [True]) + result = await plc.tag.read_all(include_system=True) + assert "Temp" in result + assert "SysFlag" in result + + @pytest.mark.asyncio + async def test_write_tag(self): + plc = self._plc_with_tags() + await plc.tag.write("Temp", 30.0) + _get_write_registers_mock(plc).assert_called_once() + + @pytest.mark.asyncio + async def test_write_missing_tag_raises(self): + plc = self._plc_with_tags() + with pytest.raises(KeyError, match="not found"): + await plc.tag.write("NonExistent", 42) + + @pytest.mark.asyncio + async def test_write_tag_case_insensitive(self): + plc = self._plc_with_tags() + await plc.tag.write("temp", 30.0) + _get_write_registers_mock(plc).assert_called_once() + + +# ============================================================================== +# TXT write tests (mocked) +# ============================================================================== + + +class TestAddressAccessorTxtWrite: + @pytest.mark.asyncio + async def test_write_single_txt(self): + plc = _make_plc() + # Mock read of current register value (for twin byte preservation) + _set_read_registers(plc, [0]) + await plc.txt.write(1, "A") + _get_write_registers_mock(plc).assert_called_once() + + @pytest.mark.asyncio + async def test_write_empty_string_clears_txt(self): + plc = _make_plc() + _set_read_registers(plc, [0x4142]) # "AB" + await plc.txt.write(1, "") + # Empty string → null byte in low position, high byte preserved + _get_write_registers_mock(plc).assert_called_once_with( + MODBUS_MAPPINGS["TXT"].base, [0x4100] + ) + + @pytest.mark.asyncio + async def test_write_txt_list(self): + plc = _make_plc() + _set_read_registers(plc, [0]) + await plc.txt.write(1, ["H", "i"]) + assert _get_write_registers_mock(plc).call_count == 2 + + +# ============================================================================== +# ModbusResponse +# ============================================================================== + + +class TestModbusResponse: + def _sample(self) -> ModbusResponse: + return ModbusResponse({"DS1": 10, "DS2": 20, "DS3": 30}) + + # -- __getitem__ -------------------------------------------------------- + + def test_getitem_exact_key(self): + r = self._sample() + assert r["DS1"] == 10 + + def test_getitem_normalized_key(self): + r = self._sample() + assert r["ds1"] == 10 + + def test_getitem_missing_raises(self): + r = self._sample() + with pytest.raises(KeyError): + r["DS999"] + + def test_getitem_invalid_raises(self): + r = self._sample() + with pytest.raises(KeyError): + r["invalid"] + + # -- __contains__ ------------------------------------------------------- + + def test_contains_exact(self): + r = self._sample() + assert "DS1" in r + + def test_contains_normalized(self): + r = self._sample() + assert "ds2" in r + + def test_not_contains(self): + r = self._sample() + assert "DS999" not in r + + def test_contains_non_string(self): + r = self._sample() + assert 42 not in r + + # -- __len__ / __iter__ ------------------------------------------------- + + def test_len(self): + r = self._sample() + assert len(r) == 3 + + def test_iter(self): + r = self._sample() + assert list(r) == ["DS1", "DS2", "DS3"] + + # -- Mapping methods (inherited) ---------------------------------------- + + def test_keys(self): + r = self._sample() + assert list(r.keys()) == ["DS1", "DS2", "DS3"] + + def test_values(self): + r = self._sample() + assert list(r.values()) == [10, 20, 30] + + def test_items(self): + r = self._sample() + assert list(r.items()) == [("DS1", 10), ("DS2", 20), ("DS3", 30)] + + def test_get_existing(self): + r = self._sample() + assert r.get("DS1") == 10 + + def test_get_normalized(self): + r = self._sample() + assert r.get("ds3") == 30 + + def test_get_missing_default(self): + r = self._sample() + assert r.get("DS999", -1) == -1 + + def test_get_missing_none(self): + r = self._sample() + assert r.get("DS999") is None + + # -- __eq__ ------------------------------------------------------------- + + def test_eq_modbus_response(self): + a = ModbusResponse({"DS1": 10, "DS2": 20}) + b = ModbusResponse({"DS1": 10, "DS2": 20}) + assert a == b + + def test_eq_modbus_response_mismatch(self): + a = ModbusResponse({"DS1": 10}) + b = ModbusResponse({"DS1": 99}) + assert a != b + + def test_eq_dict_uppercase(self): + r = ModbusResponse({"DS1": 10, "DS2": 20}) + assert r == {"DS1": 10, "DS2": 20} + + def test_eq_dict_normalized(self): + r = ModbusResponse({"DS1": 10, "DS2": 20}) + assert r == {"ds1": 10, "ds2": 20} + + def test_eq_dict_length_mismatch(self): + r = ModbusResponse({"DS1": 10}) + assert r != {"DS1": 10, "DS2": 20} + + def test_eq_other_type(self): + r = self._sample() + assert r != 42 + + # -- isinstance checks -------------------------------------------------- + + def test_not_dict_instance(self): + from collections.abc import Mapping + + r = self._sample() + assert not isinstance(r, dict) + assert isinstance(r, Mapping) + + # -- __repr__ ----------------------------------------------------------- + + def test_repr(self): + r = ModbusResponse({"DS1": 10}) + assert repr(r) == "ModbusResponse({'DS1': 10})" + + +# ============================================================================== +# AddressAccessor.__getitem__ +# ============================================================================== + + +class TestAddressAccessorGetitem: + @pytest.mark.asyncio + async def test_getitem_int(self): + plc = _make_plc() + _set_read_registers(plc, [42]) + value = await plc.ds[1] + assert value == 42 + + @pytest.mark.asyncio + async def test_getitem_float(self): + plc = _make_plc() + regs = pack_value(3.14, DataType.FLOAT) + _set_read_registers(plc, regs) + value = await plc.df[1] + import math + + assert math.isclose(value, 3.14, rel_tol=1e-6) + + @pytest.mark.asyncio + async def test_getitem_bool(self): + plc = _make_plc() + _set_read_coils(plc, [True]) + value = await plc.c[1] + assert value is True + + @pytest.mark.asyncio + async def test_getitem_xd_is_display_indexed(self): + plc = _make_plc() + + async def fake_read_registers(address: int, count: int, bank: str) -> list[int]: + del bank + return [address + i for i in range(count)] + + object.__setattr__(plc, "_read_registers", AsyncMock(side_effect=fake_read_registers)) + value = await plc.xd[3] + assert value == 57350 + + @pytest.mark.asyncio + async def test_getitem_slice_raises(self): + plc = _make_plc() + with pytest.raises(TypeError, match="Slicing is not supported"): + cast(Any, plc.ds)[1:5] + + @pytest.mark.asyncio + async def test_getitem_out_of_range_raises(self): + plc = _make_plc() + with pytest.raises(ValueError): + await plc.df[0] + + @pytest.mark.asyncio + async def test_getitem_xd_out_of_range_raises(self): + plc = _make_plc() + with pytest.raises(ValueError): + await plc.xd[9] diff --git a/tests/test_dataview.py b/tests/test_dataview.py new file mode 100644 index 0000000..543652d --- /dev/null +++ b/tests/test_dataview.py @@ -0,0 +1,700 @@ +"""Tests for pyclickplc.dataview - DataView model and CDV file I/O.""" + +import pytest + +from pyclickplc.banks import DataType +from pyclickplc.dataview import ( + MAX_DATAVIEW_ROWS, + WRITABLE_SC, + WRITABLE_SD, + DataViewFile, + DataViewRecord, + DisplayParseResult, + _CdvStorageCode, + check_cdv_file, + create_empty_dataview, + datatype_to_display, + datatype_to_storage, + display_to_datatype, + get_data_type_for_address, + is_address_writable, + read_cdv, + storage_to_datatype, + validate_new_value, + verify_cdv, + write_cdv, +) + + +def load_cdv(path): + dataview = read_cdv(path) + return dataview.rows, dataview.has_new_values, dataview.header + + +def save_cdv(path, rows, has_new_values: bool, header: str | None = None): + dataview = DataViewFile( + rows=rows, + has_new_values=has_new_values, + header=header or f"{-1 if has_new_values else 0},0,0", + ) + write_cdv(path, dataview) + + +class TestGetDataTypeForAddress: + """Tests for get_data_type_for_address function.""" + + def test_bit_addresses(self): + assert get_data_type_for_address("X001") == DataType.BIT + assert get_data_type_for_address("Y001") == DataType.BIT + assert get_data_type_for_address("C1") == DataType.BIT + assert get_data_type_for_address("T1") == DataType.BIT + assert get_data_type_for_address("CT1") == DataType.BIT + assert get_data_type_for_address("SC1") == DataType.BIT + + def test_int_addresses(self): + assert get_data_type_for_address("DS1") == DataType.INT + assert get_data_type_for_address("TD1") == DataType.INT + assert get_data_type_for_address("SD1") == DataType.INT + + def test_int2_addresses(self): + assert get_data_type_for_address("DD1") == DataType.INT2 + assert get_data_type_for_address("CTD1") == DataType.INT2 + + def test_hex_addresses(self): + assert get_data_type_for_address("DH1") == DataType.HEX + assert get_data_type_for_address("XD0") == DataType.HEX + assert get_data_type_for_address("YD0") == DataType.HEX + + def test_float_addresses(self): + assert get_data_type_for_address("DF1") == DataType.FLOAT + + def test_txt_addresses(self): + assert get_data_type_for_address("TXT1") == DataType.TXT + + def test_invalid_address(self): + assert get_data_type_for_address("INVALID") is None + assert get_data_type_for_address("") is None + + +class TestIsAddressWritable: + """Tests for is_address_writable function.""" + + def test_regular_addresses_writable(self): + assert is_address_writable("X001") is True + assert is_address_writable("Y001") is True + assert is_address_writable("C1") is True + assert is_address_writable("DS1") is True + assert is_address_writable("DD1") is True + assert is_address_writable("DF1") is True + + def test_xd_yd_readonly(self): + assert is_address_writable("XD0") is False + assert is_address_writable("XD0u") is False + assert is_address_writable("YD0") is False + assert is_address_writable("YD8") is False + + def test_sc_writable_addresses(self): + for addr in WRITABLE_SC: + assert is_address_writable(f"SC{addr}") is True + + def test_sc_readonly_addresses(self): + assert is_address_writable("SC1") is False + assert is_address_writable("SC100") is False + + def test_sd_writable_addresses(self): + for addr in WRITABLE_SD: + assert is_address_writable(f"SD{addr}") is True + + def test_sd_readonly_addresses(self): + assert is_address_writable("SD1") is False + assert is_address_writable("SD100") is False + + def test_invalid_address(self): + assert is_address_writable("INVALID") is False + assert is_address_writable("") is False + + +class TestDataViewRecord: + """Tests for DataViewRecord dataclass.""" + + def test_default_values(self): + row = DataViewRecord() + assert row.address == "" + assert row.data_type is None + assert row.new_value is None + assert row.nickname == "" + assert row.comment == "" + + def test_is_empty(self): + row = DataViewRecord() + assert row.is_empty is True + + row.address = "X001" + assert row.is_empty is False + + row.address = " " + assert row.is_empty is True + + def test_is_writable(self): + row = DataViewRecord(address="X001") + assert row.is_writable is True + + row.address = "XD0" + assert row.is_writable is False + + def test_memory_type(self): + row = DataViewRecord(address="DS100") + assert row.memory_type == "DS" + + row.address = "" + assert row.memory_type is None + + def test_address_number(self): + row = DataViewRecord(address="DS100") + assert row.address_number == "100" + + row.address = "XD0u" + assert row.address_number == "0u" + + def test_update_data_type(self): + row = DataViewRecord(address="DS100") + assert row.update_data_type() is True + assert row.data_type == DataType.INT + + row.address = "INVALID" + assert row.update_data_type() is False + + def test_clear(self): + row = DataViewRecord( + address="X001", + data_type=DataType.BIT, + new_value=True, + nickname="Test", + comment="Comment", + ) + row.clear() + assert row.address == "" + assert row.data_type is None + assert row.new_value is None + assert row.nickname == "" + assert row.comment == "" + + +class TestCreateEmptyDataview: + """Tests for create_empty_dataview function.""" + + def test_creates_correct_count(self): + rows = create_empty_dataview() + assert len(rows) == MAX_DATAVIEW_ROWS + + def test_all_rows_empty(self): + rows = create_empty_dataview() + assert all(row.is_empty for row in rows) + + def test_rows_are_independent(self): + rows = create_empty_dataview() + rows[0].address = "X001" + assert rows[1].address == "" + + +class TestStorageToDatatype: + """Tests for storage_to_datatype: CDV string -> native Python type.""" + + def test_bit_values(self): + assert storage_to_datatype("1", DataType.BIT) is True + assert storage_to_datatype("0", DataType.BIT) is False + + def test_int_positive(self): + assert storage_to_datatype("0", DataType.INT) == 0 + assert storage_to_datatype("100", DataType.INT) == 100 + assert storage_to_datatype("32767", DataType.INT) == 32767 + + def test_int_negative(self): + assert storage_to_datatype("4294934528", DataType.INT) == -32768 + assert storage_to_datatype("4294967295", DataType.INT) == -1 + assert storage_to_datatype("65535", DataType.INT) == -1 + + def test_int2_positive(self): + assert storage_to_datatype("0", DataType.INT2) == 0 + assert storage_to_datatype("100", DataType.INT2) == 100 + assert storage_to_datatype("2147483647", DataType.INT2) == 2147483647 + + def test_int2_negative(self): + assert storage_to_datatype("2147483648", DataType.INT2) == -2147483648 + assert storage_to_datatype("4294967294", DataType.INT2) == -2 + assert storage_to_datatype("4294967295", DataType.INT2) == -1 + + def test_hex_values(self): + assert storage_to_datatype("65535", DataType.HEX) == 65535 + assert storage_to_datatype("255", DataType.HEX) == 255 + assert storage_to_datatype("0", DataType.HEX) == 0 + + def test_float_values(self): + assert storage_to_datatype("0", DataType.FLOAT) == 0.0 + assert storage_to_datatype("1065353216", DataType.FLOAT) == 1.0 + val = storage_to_datatype("1078523331", DataType.FLOAT) + assert val == pytest.approx(3.14, abs=1e-5) + val = storage_to_datatype("4286578685", DataType.FLOAT) + assert isinstance(val, (int, float)) + assert val < 0 + + def test_txt_values(self): + assert storage_to_datatype("48", DataType.TXT) == "0" + assert storage_to_datatype("65", DataType.TXT) == "A" + assert storage_to_datatype("90", DataType.TXT) == "Z" + assert storage_to_datatype("32", DataType.TXT) == " " + + def test_empty_value(self): + assert storage_to_datatype("", DataType.INT) is None + assert storage_to_datatype("", DataType.HEX) is None + assert storage_to_datatype("", DataType.BIT) is None + + def test_invalid_value(self): + assert storage_to_datatype("abc", DataType.INT) is None + + +class TestDatatypeToStorage: + """Tests for datatype_to_storage: native Python type -> CDV string.""" + + def test_bit_values(self): + assert datatype_to_storage(True, DataType.BIT) == "1" + assert datatype_to_storage(False, DataType.BIT) == "0" + assert datatype_to_storage(1, DataType.BIT) == "1" + assert datatype_to_storage(0, DataType.BIT) == "0" + + def test_int_positive(self): + assert datatype_to_storage(0, DataType.INT) == "0" + assert datatype_to_storage(100, DataType.INT) == "100" + assert datatype_to_storage(32767, DataType.INT) == "32767" + + def test_int_negative(self): + assert datatype_to_storage(-32768, DataType.INT) == "4294934528" + assert datatype_to_storage(-1, DataType.INT) == "4294967295" + + def test_int2_positive(self): + assert datatype_to_storage(0, DataType.INT2) == "0" + assert datatype_to_storage(100, DataType.INT2) == "100" + + def test_int2_negative(self): + assert datatype_to_storage(-2147483648, DataType.INT2) == "2147483648" + assert datatype_to_storage(-2, DataType.INT2) == "4294967294" + + def test_hex_values(self): + assert datatype_to_storage(65535, DataType.HEX) == "65535" + assert datatype_to_storage(255, DataType.HEX) == "255" + assert datatype_to_storage(0, DataType.HEX) == "0" + + def test_float_values(self): + assert datatype_to_storage(0.0, DataType.FLOAT) == "0" + assert datatype_to_storage(1.0, DataType.FLOAT) == "1065353216" + assert datatype_to_storage(-1.0, DataType.FLOAT) == "3212836864" + + def test_txt_values(self): + assert datatype_to_storage(48, DataType.TXT) == "48" + assert datatype_to_storage(65, DataType.TXT) == "65" + assert datatype_to_storage(90, DataType.TXT) == "90" + + def test_none_value(self): + assert datatype_to_storage(None, DataType.INT) == "" + assert datatype_to_storage(None, DataType.HEX) == "" + + +class TestDatatypeToDisplay: + """Tests for datatype_to_display: native Python type -> UI string.""" + + def test_bit_values(self): + assert datatype_to_display(True, DataType.BIT) == "1" + assert datatype_to_display(False, DataType.BIT) == "0" + + def test_int_values(self): + assert datatype_to_display(0, DataType.INT) == "0" + assert datatype_to_display(100, DataType.INT) == "100" + assert datatype_to_display(-32768, DataType.INT) == "-32768" + assert datatype_to_display(32767, DataType.INT) == "32767" + + def test_int2_values(self): + assert datatype_to_display(0, DataType.INT2) == "0" + assert datatype_to_display(-2147483648, DataType.INT2) == "-2147483648" + assert datatype_to_display(2147483647, DataType.INT2) == "2147483647" + + def test_hex_values(self): + assert datatype_to_display(65535, DataType.HEX) == "FFFF" + assert datatype_to_display(255, DataType.HEX) == "00FF" + assert datatype_to_display(0, DataType.HEX) == "0000" + assert datatype_to_display(1, DataType.HEX) == "0001" + + def test_float_values(self): + assert datatype_to_display(0.0, DataType.FLOAT) == "0" + assert datatype_to_display(1.0, DataType.FLOAT) == "1" + assert datatype_to_display(3.1400001049041748, DataType.FLOAT) == "3.14" + assert datatype_to_display(3.4028234663852886e38, DataType.FLOAT) == "3.402823E+38" + assert datatype_to_display(-3.4028234663852886e38, DataType.FLOAT) == "-3.402823E+38" + + def test_txt_printable(self): + assert datatype_to_display(48, DataType.TXT) == "0" + assert datatype_to_display(65, DataType.TXT) == "A" + assert datatype_to_display(90, DataType.TXT) == "Z" + assert datatype_to_display(32, DataType.TXT) == " " + + def test_txt_nonprintable(self): + assert datatype_to_display(5, DataType.TXT) == "5" + assert datatype_to_display(127, DataType.TXT) == "127" + + def test_none_value(self): + assert datatype_to_display(None, DataType.INT) == "" + assert datatype_to_display(None, DataType.HEX) == "" + + +class TestDisplayToDatatype: + """Tests for display_to_datatype: UI string -> native Python type.""" + + def test_bit_values(self): + assert display_to_datatype("1", DataType.BIT) is True + assert display_to_datatype("0", DataType.BIT) is False + assert display_to_datatype("True", DataType.BIT) is True + assert display_to_datatype("ON", DataType.BIT) is True + + def test_int_values(self): + assert display_to_datatype("0", DataType.INT) == 0 + assert display_to_datatype("100", DataType.INT) == 100 + assert display_to_datatype("-32768", DataType.INT) == -32768 + assert display_to_datatype("32767", DataType.INT) == 32767 + + def test_int2_values(self): + assert display_to_datatype("0", DataType.INT2) == 0 + assert display_to_datatype("-2147483648", DataType.INT2) == -2147483648 + assert display_to_datatype("2147483647", DataType.INT2) == 2147483647 + + def test_hex_values(self): + assert display_to_datatype("FFFF", DataType.HEX) == 65535 + assert display_to_datatype("FF", DataType.HEX) == 255 + assert display_to_datatype("0xFF", DataType.HEX) == 255 + assert display_to_datatype("0", DataType.HEX) == 0 + + def test_float_values(self): + assert display_to_datatype("3.14", DataType.FLOAT) == pytest.approx(3.14) + assert display_to_datatype("0.0", DataType.FLOAT) == 0.0 + assert display_to_datatype("-1.0", DataType.FLOAT) == -1.0 + + def test_txt_char(self): + assert display_to_datatype("A", DataType.TXT) == "A" + assert display_to_datatype("Z", DataType.TXT) == "Z" + assert display_to_datatype("0", DataType.TXT) == "0" + assert display_to_datatype(" ", DataType.TXT) == " " + + def test_txt_numeric(self): + assert display_to_datatype("65", DataType.TXT) == "A" + + def test_empty_value(self): + assert display_to_datatype("", DataType.INT) is None + assert display_to_datatype("", DataType.HEX) is None + + def test_invalid_value(self): + assert display_to_datatype("abc", DataType.INT) is None + + +class TestRoundTripConversion: + """Tests for round-trip conversions across layers.""" + + def test_storage_datatype_roundtrip_int(self): + for storage_val in ["0", "100", "32767", "4294934528", "4294967295"]: + native = storage_to_datatype(storage_val, DataType.INT) + storage = datatype_to_storage(native, DataType.INT) + assert storage_to_datatype(storage, DataType.INT) == native + + def test_storage_datatype_roundtrip_int2(self): + for storage_val in ["0", "100", "2147483647", "2147483648", "4294967294"]: + native = storage_to_datatype(storage_val, DataType.INT2) + storage = datatype_to_storage(native, DataType.INT2) + assert storage_to_datatype(storage, DataType.INT2) == native + + def test_storage_datatype_roundtrip_hex(self): + for storage_val in ["0", "255", "65535"]: + native = storage_to_datatype(storage_val, DataType.HEX) + storage = datatype_to_storage(native, DataType.HEX) + assert storage == storage_val + + def test_storage_datatype_roundtrip_float(self): + for storage_val in ["0", "1065353216", "3212836864"]: + native = storage_to_datatype(storage_val, DataType.FLOAT) + storage = datatype_to_storage(native, DataType.FLOAT) + assert storage == storage_val + + def test_storage_datatype_roundtrip_txt(self): + for storage_val in ["32", "48", "65", "90"]: + native = storage_to_datatype(storage_val, DataType.TXT) + storage = datatype_to_storage(native, DataType.TXT) + assert storage == storage_val + + def test_display_datatype_roundtrip_hex(self): + for display_val, expected in [("0", "0000"), ("FF", "00FF"), ("FFFF", "FFFF")]: + native = display_to_datatype(display_val, DataType.HEX) + display = datatype_to_display(native, DataType.HEX) + assert display == expected + + def test_display_datatype_roundtrip_txt(self): + for char in ["A", "Z", "0", " "]: + native = display_to_datatype(char, DataType.TXT) + display = datatype_to_display(native, DataType.TXT) + assert display == char + + def test_full_pipeline_snapshot(self): + """Full pipeline: CDV storage -> datatype -> display string.""" + cases = [ + ("4286578685", DataType.FLOAT, "-3.402823E+38"), + ("2139095037", DataType.FLOAT, "3.402823E+38"), + ("0", DataType.HEX, "0000"), + ("65535", DataType.HEX, "FFFF"), + ("1", DataType.HEX, "0001"), + ("4294967295", DataType.INT, "-1"), + ("4294967295", DataType.INT2, "-1"), + ] + for storage_val, data_type, expected_display in cases: + native = storage_to_datatype(storage_val, data_type) + display = datatype_to_display(native, data_type) + assert display == expected_display, ( + f"Pipeline failed for {storage_val} (type {data_type}): " + f"got {display!r}, expected {expected_display!r}" + ) + + def test_full_pipeline_float_pi(self): + """Full pipeline for pi-ish float value.""" + native = storage_to_datatype("1078523331", DataType.FLOAT) + display = datatype_to_display(native, DataType.FLOAT) + assert display.startswith("3.14") + + +class TestValidateNewValue: + def test_empty_is_valid(self): + assert validate_new_value("", DataType.INT) == (True, "") + + def test_invalid_int(self): + assert validate_new_value("abc", DataType.INT) == (False, "Must be integer") + + def test_valid_int(self): + assert validate_new_value("100", DataType.INT) == (True, "") + + +class TestDataViewFileDisplayHelpers: + def test_value_to_display(self): + assert DataViewFile.value_to_display(100, DataType.INT) == "100" + assert DataViewFile.value_to_display(None, DataType.INT) == "" + + def test_try_parse_display(self): + parsed = DataViewFile.try_parse_display("100", DataType.INT) + assert parsed == DisplayParseResult(ok=True, value=100, error="") + + parsed_empty = DataViewFile.try_parse_display("", DataType.INT) + assert parsed_empty == DisplayParseResult(ok=True, value=None, error="") + + parsed_invalid = DataViewFile.try_parse_display("abc", DataType.INT) + assert parsed_invalid.ok is False + assert parsed_invalid.error == "Must be integer" + + def test_validate_row_display(self): + row = DataViewRecord(address="XD0", data_type=DataType.HEX) + assert DataViewFile.validate_row_display(row, "0001") == (False, "Read-only address") + + row = DataViewRecord(address="DS1") + assert DataViewFile.validate_row_display(row, "100") == (False, "No address set") + + row = DataViewRecord(address="DS1", data_type=DataType.INT) + assert DataViewFile.validate_row_display(row, "abc") == (False, "Must be integer") + + def test_set_row_new_value_from_display(self): + row = DataViewRecord(address="DS1", data_type=DataType.INT) + DataViewFile.set_row_new_value_from_display(row, "100") + assert row.new_value == 100 + + DataViewFile.set_row_new_value_from_display(row, "") + assert row.new_value is None + + with pytest.raises(ValueError, match="Must be integer"): + DataViewFile.set_row_new_value_from_display(row, "abc") + + +class TestLoadCdv: + """Tests for load_cdv function.""" + + def test_load_basic_cdv(self, tmp_path): + cdv = tmp_path / "test.cdv" + lines = ["0,0,0\n"] + lines.append(f"X001,{_CdvStorageCode.BIT}\n") + lines.append(f"DS1,{_CdvStorageCode.INT}\n") + for _ in range(98): + lines.append(",0\n") + cdv.write_text("".join(lines), encoding="utf-16") + + rows, has_new_values, header = load_cdv(cdv) + assert len(rows) == MAX_DATAVIEW_ROWS + assert has_new_values is False + assert rows[0].address == "X001" + assert rows[0].data_type == DataType.BIT + assert rows[1].address == "DS1" + assert rows[1].data_type == DataType.INT + assert rows[2].is_empty + assert header == "0,0,0" + + def test_load_infers_data_type_when_missing(self, tmp_path): + cdv = tmp_path / "test.cdv" + lines = ["0,0,0\n", "DS1,\n"] + for _ in range(99): + lines.append(",0\n") + cdv.write_text("".join(lines), encoding="utf-16") + + rows, _has_new_values, _header = load_cdv(cdv) + assert rows[0].data_type == DataType.INT + + def test_load_with_new_values(self, tmp_path): + cdv = tmp_path / "test.cdv" + lines = ["-1,0,0\n"] + lines.append(f"X001,{_CdvStorageCode.BIT},1\n") + for _ in range(99): + lines.append(",0\n") + cdv.write_text("".join(lines), encoding="utf-16") + + rows, has_new_values, _header = load_cdv(cdv) + assert has_new_values is True + assert rows[0].new_value is True + + def test_load_nonexistent(self, tmp_path): + with pytest.raises(FileNotFoundError): + load_cdv(tmp_path / "missing.cdv") + + +class TestSaveCdv: + """Tests for save_cdv function.""" + + def test_save_and_reload(self, tmp_path): + cdv = tmp_path / "test.cdv" + rows = create_empty_dataview() + rows[0].address = "X001" + rows[0].data_type = DataType.BIT + rows[1].address = "DS1" + rows[1].data_type = DataType.INT + + save_cdv(cdv, rows, has_new_values=False) + + loaded_rows, has_new_values, _header = load_cdv(cdv) + assert has_new_values is False + assert loaded_rows[0].address == "X001" + assert loaded_rows[0].data_type == DataType.BIT + assert loaded_rows[1].address == "DS1" + assert loaded_rows[1].data_type == DataType.INT + assert loaded_rows[2].is_empty + + def test_save_with_new_values(self, tmp_path): + cdv = tmp_path / "test.cdv" + rows = create_empty_dataview() + rows[0].address = "X001" + rows[0].data_type = DataType.BIT + rows[0].new_value = True + + save_cdv(cdv, rows, has_new_values=True) + + loaded_rows, has_new_values, _header = load_cdv(cdv) + assert has_new_values is True + assert loaded_rows[0].new_value is True + + +class TestDataViewFileIO: + def test_read_write_aliases(self, tmp_path): + cdv = tmp_path / "test.cdv" + rows = create_empty_dataview() + rows[0].address = "DS1" + rows[0].data_type = DataType.INT + rows[0].new_value = 42 + save_cdv(cdv, rows, has_new_values=True) + + dataview = read_cdv(cdv) + assert isinstance(dataview, DataViewFile) + assert dataview.rows[0].new_value == 42 + + out = tmp_path / "out.cdv" + write_cdv(out, dataview) + loaded_rows, has_new_values, _header = load_cdv(out) + assert has_new_values is True + assert loaded_rows[0].new_value == 42 + + def test_byte_identical_roundtrip(self, tmp_path): + cdv = tmp_path / "test.cdv" + lines = ["-1,0,0\n", f"DS1,{_CdvStorageCode.INT},100\n"] + for _ in range(99): + lines.append(",0\n") + cdv.write_text("".join(lines), encoding="utf-16") + original_bytes = cdv.read_bytes() + + dataview = DataViewFile.load(cdv) + dataview.save() + + assert cdv.read_bytes() == original_bytes + + def test_verify_cdv_int_float_equivalent(self, tmp_path): + cdv = tmp_path / "test.cdv" + lines = ["-1,0,0\n", f"DS1,{_CdvStorageCode.INT},1\n"] + for _ in range(99): + lines.append(",0\n") + cdv.write_text("".join(lines), encoding="utf-16") + + rows = create_empty_dataview() + rows[0].address = "DS1" + rows[0].data_type = DataType.INT + rows[0].new_value = 1.0 + + assert verify_cdv(cdv, rows, has_new_values=True) == [] + + +class TestCheckCdv: + def test_check_cdv_file_valid(self, tmp_path): + cdv = tmp_path / "valid.cdv" + rows = create_empty_dataview() + rows[0].address = "X001" + rows[0].data_type = DataType.BIT + save_cdv(cdv, rows, has_new_values=False) + + assert check_cdv_file(cdv) == [] + + def test_check_cdv_file_invalid_address(self, tmp_path): + cdv = tmp_path / "invalid-address.cdv" + rows = create_empty_dataview() + rows[0].address = "INVALID" + rows[0].data_type = DataType.BIT + save_cdv(cdv, rows, has_new_values=False) + + issues = check_cdv_file(cdv) + assert len(issues) == 1 + assert "Invalid address format" in issues[0] + + def test_check_cdv_file_type_mismatch(self, tmp_path): + cdv = tmp_path / "mismatch.cdv" + rows = create_empty_dataview() + rows[0].address = "DS1" + rows[0].data_type = DataType.BIT + save_cdv(cdv, rows, has_new_values=False) + + issues = check_cdv_file(cdv) + assert len(issues) == 1 + assert "Data type mismatch" in issues[0] + + def test_check_cdv_file_invalid_new_value_bit(self, tmp_path): + cdv = tmp_path / "invalid-bit.cdv" + lines = ["-1,0,0\n", f"X001,{_CdvStorageCode.BIT},2\n"] + for _ in range(99): + lines.append(",0\n") + cdv.write_text("".join(lines), encoding="utf-16") + + issues = check_cdv_file(cdv) + assert len(issues) == 1 + assert "invalid for BIT" in issues[0] + + def test_check_cdv_file_non_writable_with_new_value(self, tmp_path): + cdv = tmp_path / "non-writable.cdv" + rows = create_empty_dataview() + rows[0].address = "XD0" + rows[0].data_type = DataType.HEX + rows[0].new_value = 1 + save_cdv(cdv, rows, has_new_values=True) + + issues = check_cdv_file(cdv) + assert len(issues) == 1 + assert "not writable" in issues[0] diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..64f3abe --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,258 @@ +"""Integration tests: ClickClient <-> ClickServer round-trips. + +Tests the full stack: driver -> pymodbus client -> TCP -> pymodbus server +-> _ClickDeviceContext -> MemoryDataProvider. +""" + +from __future__ import annotations + +import asyncio +import math + +import pytest +from pymodbus.exceptions import ConnectionException + +from pyclickplc.client import ClickClient +from pyclickplc.server import ClickServer, MemoryDataProvider + +# Port for integration tests (avoid 502 which needs root) +TEST_PORT = 15020 + + +@pytest.fixture +async def plc_fixture(): + """Provide a connected ClickClient and MemoryDataProvider.""" + provider = MemoryDataProvider() + async with ClickServer(provider, port=TEST_PORT): + # Small delay for server to be ready + await asyncio.sleep(0.1) + async with ClickClient("localhost", TEST_PORT) as plc: + yield plc, provider + + +# ============================================================================== +# Basic round-trips by data type +# ============================================================================== + + +class TestRoundTrips: + @pytest.mark.asyncio + async def test_float_round_trip(self, plc_fixture): + plc, provider = plc_fixture + await plc.df.write(1, 3.14) + result = await plc.df.read(1) + assert math.isclose(result["DF1"], 3.14, rel_tol=1e-6) + assert math.isclose(provider.get("DF1"), 3.14, rel_tol=1e-6) + + @pytest.mark.asyncio + async def test_int32_round_trip(self, plc_fixture): + plc, provider = plc_fixture + await plc.dd.write(1, 100000) + result = await plc.dd.read(1) + assert result == {"DD1": 100000} + assert provider.get("DD1") == 100000 + + @pytest.mark.asyncio + async def test_int16_signed_positive(self, plc_fixture): + plc, provider = plc_fixture + await plc.ds.write(1, 1234) + result = await plc.ds.read(1) + assert result == {"DS1": 1234} + + @pytest.mark.asyncio + async def test_int16_signed_negative(self, plc_fixture): + plc, provider = plc_fixture + await plc.ds.write(1, -1234) + result = await plc.ds.read(1) + assert result == {"DS1": -1234} + + @pytest.mark.asyncio + async def test_int16_unsigned_dh(self, plc_fixture): + plc, provider = plc_fixture + await plc.dh.write(1, 0xABCD) + result = await plc.dh.read(1) + assert result == {"DH1": 0xABCD} + + @pytest.mark.asyncio + async def test_bool_round_trip(self, plc_fixture): + plc, provider = plc_fixture + await plc.c.write(1, True) + result = await plc.c.read(1) + assert result == {"C1": True} + assert provider.get("C1") is True + + @pytest.mark.asyncio + async def test_bool_false(self, plc_fixture): + plc, provider = plc_fixture + await plc.c.write(1, True) + await plc.c.write(1, False) + result = await plc.c.read(1) + assert result == {"C1": False} + + @pytest.mark.asyncio + async def test_text_round_trip(self, plc_fixture): + plc, provider = plc_fixture + await plc.txt.write(1, "A") + result = await plc.txt.read(1) + assert result == {"TXT1": "A"} + + +# ============================================================================== +# Provider -> Driver reads +# ============================================================================== + + +class TestProviderToDriver: + @pytest.mark.asyncio + async def test_provider_set_driver_reads(self, plc_fixture): + plc, provider = plc_fixture + provider.set("DF1", 99.5) + result = await plc.df.read(1) + assert math.isclose(result["DF1"], 99.5, rel_tol=1e-6) + + @pytest.mark.asyncio + async def test_provider_set_bool(self, plc_fixture): + plc, provider = plc_fixture + provider.set("C1", True) + result = await plc.c.read(1) + assert result == {"C1": True} + + @pytest.mark.asyncio + async def test_provider_set_ds(self, plc_fixture): + plc, provider = plc_fixture + provider.set("DS1", -42) + result = await plc.ds.read(1) + assert result == {"DS1": -42} + + +# ============================================================================== +# Range reads +# ============================================================================== + + +class TestRangeReads: + @pytest.mark.asyncio + async def test_read_df_range(self, plc_fixture): + plc, provider = plc_fixture + provider.set("DF1", 1.0) + provider.set("DF2", 2.0) + provider.set("DF3", 3.0) + result = await plc.df.read(1, 3) + assert len(result) == 3 + assert math.isclose(result["DF1"], 1.0, rel_tol=1e-6) + assert math.isclose(result["DF2"], 2.0, rel_tol=1e-6) + assert math.isclose(result["DF3"], 3.0, rel_tol=1e-6) + + @pytest.mark.asyncio + async def test_read_ds_range(self, plc_fixture): + plc, provider = plc_fixture + for i in range(1, 6): + provider.set(f"DS{i}", i * 10) + result = await plc.ds.read(1, 5) + assert len(result) == 5 + assert result["DS1"] == 10 + assert result["DS5"] == 50 + + +# ============================================================================== +# Range writes +# ============================================================================== + + +class TestRangeWrites: + @pytest.mark.asyncio + async def test_write_df_list(self, plc_fixture): + plc, provider = plc_fixture + await plc.df.write(1, [1.0, 2.0, 3.0]) + assert math.isclose(provider.get("DF1"), 1.0, rel_tol=1e-6) + assert math.isclose(provider.get("DF2"), 2.0, rel_tol=1e-6) + assert math.isclose(provider.get("DF3"), 3.0, rel_tol=1e-6) + + @pytest.mark.asyncio + async def test_write_ds_list(self, plc_fixture): + plc, provider = plc_fixture + await plc.ds.write(1, [10, 20, 30]) + assert provider.get("DS1") == 10 + assert provider.get("DS2") == 20 + assert provider.get("DS3") == 30 + + +# ============================================================================== +# Writability enforcement +# ============================================================================== + + +class TestWritability: + @pytest.mark.asyncio + async def test_sc_not_writable_through_stack(self, plc_fixture): + plc, _ = plc_fixture + with pytest.raises(ValueError, match="not writable"): + await plc.sc.write(1, True) + + @pytest.mark.asyncio + async def test_sd_not_writable_through_stack(self, plc_fixture): + plc, _ = plc_fixture + with pytest.raises(ValueError, match="not writable"): + await plc.sd.write(1, 42) + + @pytest.mark.asyncio + async def test_x_not_writable(self, plc_fixture): + plc, _ = plc_fixture + with pytest.raises(ValueError, match="not writable"): + await plc.x.write(1, True) + + +# ============================================================================== +# Sparse coils +# ============================================================================== + + +class TestSparseCoils: + @pytest.mark.asyncio + async def test_y_write_read(self, plc_fixture): + plc, provider = plc_fixture + await plc.y.write(1, True) + result = await plc.y.read(1) + assert result == {"Y001": True} + + @pytest.mark.asyncio + async def test_x_read_from_provider(self, plc_fixture): + plc, provider = plc_fixture + provider.set("X001", True) + result = await plc.x.read(1) + assert result == {"X001": True} + + @pytest.mark.asyncio + async def test_x_expansion_slot(self, plc_fixture): + plc, provider = plc_fixture + provider.set("X101", True) + result = await plc.x.read(101) + assert result == {"X101": True} + + +# ============================================================================== +# Connection lifecycle +# ============================================================================== + + +class TestConnectionLifecycle: + @pytest.mark.asyncio + async def test_disconnect_all_drops_clickclient_session(self): + provider = MemoryDataProvider() + server = ClickServer(provider, host="127.0.0.1", port=TEST_PORT + 1) + await server.start() + try: + async with ClickClient("127.0.0.1", TEST_PORT + 1) as plc: + await asyncio.sleep(0.05) + assert len(server.list_clients()) == 1 + + closed = server.disconnect_all_clients() + assert closed == 1 + + await asyncio.sleep(0.2) + assert server.list_clients() == [] + + with pytest.raises(ConnectionException, match="Not connected"): + await plc.ds.read(1) + finally: + await server.stop() diff --git a/tests/test_modbus.py b/tests/test_modbus.py new file mode 100644 index 0000000..881345e --- /dev/null +++ b/tests/test_modbus.py @@ -0,0 +1,744 @@ +"""Tests for pyclickplc.modbus — Modbus protocol mapping layer.""" + +from __future__ import annotations + +import math +import struct +from typing import Any, cast + +import pytest + +from pyclickplc.banks import DataType +from pyclickplc.modbus import ( + MODBUS_MAPPINGS, + MODBUS_SIGNED, + MODBUS_WIDTH, + STRUCT_FORMATS, + modbus_to_plc, + pack_value, + plc_to_modbus, + unpack_value, +) + +# ============================================================================== +# ModbusMapping & MODBUS_MAPPINGS +# ============================================================================== + + +class TestModbusMappings: + """Tests for MODBUS_MAPPINGS dict and ModbusMapping dataclass.""" + + def test_all_16_banks_present(self): + assert len(MODBUS_MAPPINGS) == 16 + expected = { + "X", + "Y", + "C", + "T", + "CT", + "SC", + "DS", + "DD", + "DH", + "DF", + "TD", + "CTD", + "SD", + "TXT", + "XD", + "YD", + } + assert set(MODBUS_MAPPINGS) == expected + + def test_frozen_dataclass(self): + m = MODBUS_MAPPINGS["DS"] + with pytest.raises(AttributeError): + cast(Any, m).base = 999 + + def test_coil_banks(self): + coil_banks = {k for k, v in MODBUS_MAPPINGS.items() if v.is_coil} + assert coil_banks == {"X", "Y", "C", "T", "CT", "SC"} + + def test_register_banks(self): + reg_banks = {k for k, v in MODBUS_MAPPINGS.items() if not v.is_coil} + assert reg_banks == {"DS", "DD", "DH", "DF", "TD", "CTD", "SD", "TXT", "XD", "YD"} + + def test_x_read_only(self): + m = MODBUS_MAPPINGS["X"] + assert m.function_codes == frozenset({2}) + assert not m.is_writable + + def test_t_read_only(self): + assert not MODBUS_MAPPINGS["T"].is_writable + + def test_ct_read_only(self): + assert not MODBUS_MAPPINGS["CT"].is_writable + + def test_xd_read_only(self): + assert not MODBUS_MAPPINGS["XD"].is_writable + + def test_y_writable(self): + m = MODBUS_MAPPINGS["Y"] + assert m.function_codes == frozenset({1, 5, 15}) + assert m.is_writable + + def test_c_writable(self): + assert MODBUS_MAPPINGS["C"].is_writable + + def test_ds_writable(self): + m = MODBUS_MAPPINGS["DS"] + assert m.function_codes == frozenset({3, 6, 16}) + assert m.is_writable + + def test_sc_writable_subset(self): + m = MODBUS_MAPPINGS["SC"] + assert m.writable is not None + assert 53 in m.writable + assert 50 not in m.writable # ladder-only, not Modbus-writable + assert 51 not in m.writable + assert 120 in m.writable + + def test_sd_writable_subset(self): + m = MODBUS_MAPPINGS["SD"] + assert m.writable is not None + assert 29 in m.writable + assert 214 in m.writable + assert 1 not in m.writable + + def test_sd_read_only_fc(self): + """SD has FC 4 (read input registers) — not writable via FCs.""" + assert not MODBUS_MAPPINGS["SD"].is_writable + + def test_width_2_banks(self): + for bank in ("DD", "DF", "CTD"): + assert MODBUS_MAPPINGS[bank].width == 2, f"{bank} should be width 2" + + def test_width_1_banks(self): + for bank in ("DS", "DH", "TXT", "TD", "SD", "XD", "YD"): + assert MODBUS_MAPPINGS[bank].width == 1, f"{bank} should be width 1" + + +# ============================================================================== +# DataType-derived constants +# ============================================================================== + + +class TestDerivedConstants: + def test_modbus_width_keys(self): + assert set(MODBUS_WIDTH) == set(DataType) + + def test_modbus_width_values(self): + assert MODBUS_WIDTH[DataType.BIT] == 1 + assert MODBUS_WIDTH[DataType.INT2] == 2 + assert MODBUS_WIDTH[DataType.FLOAT] == 2 + + def test_modbus_signed(self): + assert MODBUS_SIGNED[DataType.INT] is True + assert MODBUS_SIGNED[DataType.HEX] is False + + def test_struct_formats(self): + assert STRUCT_FORMATS[DataType.INT] == "h" + assert STRUCT_FORMATS[DataType.INT2] == "i" + assert STRUCT_FORMATS[DataType.FLOAT] == "f" + assert STRUCT_FORMATS[DataType.HEX] == "H" + + +# ============================================================================== +# Forward mapping: plc_to_modbus +# ============================================================================== + + +class TestPlcToModbus: + """Tests for plc_to_modbus forward mapping.""" + + # --- Standard coils --- + + def test_c1(self): + assert plc_to_modbus("C", 1) == (16384, 1) + + def test_c2000(self): + assert plc_to_modbus("C", 2000) == (18383, 1) + + def test_t1(self): + assert plc_to_modbus("T", 1) == (45056, 1) + + def test_ct1(self): + assert plc_to_modbus("CT", 1) == (49152, 1) + + def test_sc1(self): + assert plc_to_modbus("SC", 1) == (61440, 1) + + # --- Sparse coils (X) --- + + def test_x001(self): + assert plc_to_modbus("X", 1) == (0, 1) + + def test_x016(self): + assert plc_to_modbus("X", 16) == (15, 1) + + def test_x021(self): + assert plc_to_modbus("X", 21) == (16, 1) + + def test_x036(self): + assert plc_to_modbus("X", 36) == (31, 1) + + def test_x101(self): + assert plc_to_modbus("X", 101) == (32, 1) + + def test_x116(self): + assert plc_to_modbus("X", 116) == (47, 1) + + def test_x201(self): + assert plc_to_modbus("X", 201) == (64, 1) + + def test_x816(self): + assert plc_to_modbus("X", 816) == (271, 1) + + # --- Sparse coils (Y) --- + + def test_y001(self): + assert plc_to_modbus("Y", 1) == (8192, 1) + + def test_y101(self): + assert plc_to_modbus("Y", 101) == (8224, 1) + + # --- Standard registers --- + + def test_ds1(self): + assert plc_to_modbus("DS", 1) == (0, 1) + + def test_ds4500(self): + assert plc_to_modbus("DS", 4500) == (4499, 1) + + def test_dd1(self): + assert plc_to_modbus("DD", 1) == (16384, 2) + + def test_dd2(self): + assert plc_to_modbus("DD", 2) == (16386, 2) + + def test_df1(self): + assert plc_to_modbus("DF", 1) == (28672, 2) + + def test_df2(self): + assert plc_to_modbus("DF", 2) == (28674, 2) + + def test_dh1(self): + assert plc_to_modbus("DH", 1) == (24576, 1) + + def test_txt1(self): + assert plc_to_modbus("TXT", 1) == (36864, 1) + + def test_txt2_shares_register_with_txt1(self): + assert plc_to_modbus("TXT", 2) == (36864, 1) + + def test_txt1000(self): + assert plc_to_modbus("TXT", 1000) == (37363, 1) + + def test_td1(self): + assert plc_to_modbus("TD", 1) == (45056, 1) + + def test_ctd1(self): + assert plc_to_modbus("CTD", 1) == (49152, 2) + + def test_ctd2(self): + assert plc_to_modbus("CTD", 2) == (49154, 2) + + def test_sd1(self): + assert plc_to_modbus("SD", 1) == (61440, 1) + + # --- XD/YD --- + + def test_xd0(self): + assert plc_to_modbus("XD", 0) == (57344, 1) + + def test_xd1(self): + # MDB index 2 = XD1 display + assert plc_to_modbus("XD", 2) == (57346, 1) + + def test_xd8(self): + # MDB index 16 = XD8 display + assert plc_to_modbus("XD", 16) == (57360, 1) + + def test_yd0(self): + assert plc_to_modbus("YD", 0) == (57856, 1) + + def test_yd1(self): + # MDB index 2 = YD1 display + assert plc_to_modbus("YD", 2) == (57858, 1) + + # --- Errors --- + + def test_invalid_bank(self): + with pytest.raises(ValueError, match="Unknown bank"): + plc_to_modbus("ZZ", 1) + + def test_invalid_index_ds0(self): + with pytest.raises(ValueError, match="Invalid address"): + plc_to_modbus("DS", 0) + + def test_invalid_index_x017(self): + with pytest.raises(ValueError, match="Invalid address"): + plc_to_modbus("X", 17) + + def test_invalid_index_ds4501(self): + with pytest.raises(ValueError, match="Invalid address"): + plc_to_modbus("DS", 4501) + + +# ============================================================================== +# Reverse mapping: modbus_to_plc +# ============================================================================== + + +class TestModbusToPlc: + """Tests for modbus_to_plc reverse mapping.""" + + # --- Sparse coils (from CLICKSERVER_SPEC test scenarios) --- + + def test_coil_0_x001(self): + assert modbus_to_plc(0, is_coil=True) == ("X", 1) + + def test_coil_15_x016(self): + assert modbus_to_plc(15, is_coil=True) == ("X", 16) + + def test_coil_16_x021(self): + assert modbus_to_plc(16, is_coil=True) == ("X", 21) + + def test_coil_31_x036(self): + assert modbus_to_plc(31, is_coil=True) == ("X", 36) + + def test_coil_32_x101(self): + assert modbus_to_plc(32, is_coil=True) == ("X", 101) + + def test_coil_47_x116(self): + assert modbus_to_plc(47, is_coil=True) == ("X", 116) + + def test_coil_64_x201(self): + assert modbus_to_plc(64, is_coil=True) == ("X", 201) + + def test_coil_8192_y001(self): + assert modbus_to_plc(8192, is_coil=True) == ("Y", 1) + + def test_coil_8208_y021(self): + assert modbus_to_plc(8208, is_coil=True) == ("Y", 21) + + def test_coil_8224_y101(self): + assert modbus_to_plc(8224, is_coil=True) == ("Y", 101) + + # --- Standard coils --- + + def test_coil_16384_c1(self): + assert modbus_to_plc(16384, is_coil=True) == ("C", 1) + + def test_coil_18383_c2000(self): + assert modbus_to_plc(18383, is_coil=True) == ("C", 2000) + + def test_coil_45057_t1(self): + assert modbus_to_plc(45056, is_coil=True) == ("T", 1) + + def test_coil_49152_ct1(self): + assert modbus_to_plc(49152, is_coil=True) == ("CT", 1) + + def test_coil_61440_sc1(self): + assert modbus_to_plc(61440, is_coil=True) == ("SC", 1) + + # --- Sparse gaps --- + + def test_coil_48_gap(self): + """Coil 48 is X slot 1 offset 48: hundred=1, unit=17 -> gap.""" + assert modbus_to_plc(48, is_coil=True) is None + + def test_coil_8240_gap(self): + """Coil 8240 = Y base + 48: hundred=1, unit=17 -> gap.""" + assert modbus_to_plc(8240, is_coil=True) is None + + # --- Unmapped coils --- + + def test_coil_10000_unmapped(self): + assert modbus_to_plc(10000, is_coil=True) is None + + # --- Standard registers --- + + def test_reg_0_ds1(self): + assert modbus_to_plc(0, is_coil=False) == ("DS", 1) + + def test_reg_4499_ds4500(self): + assert modbus_to_plc(4499, is_coil=False) == ("DS", 4500) + + def test_reg_16384_dd1(self): + assert modbus_to_plc(16384, is_coil=False) == ("DD", 1) + + def test_reg_16386_dd2(self): + assert modbus_to_plc(16386, is_coil=False) == ("DD", 2) + + def test_reg_16385_dd1_mid(self): + """Register 16385 is the second register of DD1 — mid-value.""" + assert modbus_to_plc(16385, is_coil=False) is None + + def test_reg_24576_dh1(self): + assert modbus_to_plc(24576, is_coil=False) == ("DH", 1) + + def test_reg_28672_df1(self): + assert modbus_to_plc(28672, is_coil=False) == ("DF", 1) + + def test_reg_28674_df2(self): + assert modbus_to_plc(28674, is_coil=False) == ("DF", 2) + + def test_reg_36864_txt1(self): + assert modbus_to_plc(36864, is_coil=False) == ("TXT", 1) + + def test_reg_36865_txt3(self): + assert modbus_to_plc(36865, is_coil=False) == ("TXT", 3) + + def test_reg_37363_txt999(self): + assert modbus_to_plc(37363, is_coil=False) == ("TXT", 999) + + def test_reg_45056_td1(self): + assert modbus_to_plc(45056, is_coil=False) == ("TD", 1) + + def test_reg_49152_ctd1(self): + assert modbus_to_plc(49152, is_coil=False) == ("CTD", 1) + + def test_reg_49154_ctd2(self): + assert modbus_to_plc(49154, is_coil=False) == ("CTD", 2) + + def test_reg_61440_sd1(self): + assert modbus_to_plc(61440, is_coil=False) == ("SD", 1) + + # --- XD/YD registers --- + + def test_reg_57344_xd0(self): + assert modbus_to_plc(57344, is_coil=False) == ("XD", 0) + + def test_reg_57346_xd1(self): + # MDB index 2 = XD1 display + assert modbus_to_plc(57346, is_coil=False) == ("XD", 2) + + def test_reg_57345_xd0u(self): + """Register 57345 is XD0u (MDB index 1) — now addressable.""" + assert modbus_to_plc(57345, is_coil=False) == ("XD", 1) + + def test_reg_57856_yd0(self): + assert modbus_to_plc(57856, is_coil=False) == ("YD", 0) + + def test_reg_57858_yd1(self): + # MDB index 2 = YD1 display + assert modbus_to_plc(57858, is_coil=False) == ("YD", 2) + + # --- Unmapped registers --- + + def test_reg_5000_unmapped(self): + assert modbus_to_plc(5000, is_coil=False) is None + + +# ============================================================================== +# Forward/Reverse round-trip +# ============================================================================== + + +class TestRoundTrip: + """Verify that plc_to_modbus and modbus_to_plc are inverses.""" + + @pytest.mark.parametrize( + "bank,index", + [ + ("X", 1), + ("X", 16), + ("X", 21), + ("X", 36), + ("X", 101), + ("X", 116), + ("X", 201), + ("X", 816), + ("Y", 1), + ("Y", 16), + ("Y", 101), + ("Y", 816), + ("C", 1), + ("C", 1000), + ("C", 2000), + ("T", 1), + ("T", 500), + ("CT", 1), + ("CT", 250), + ("SC", 1), + ("SC", 1000), + ], + ) + def test_coil_round_trip(self, bank: str, index: int): + addr, _ = plc_to_modbus(bank, index) + result = modbus_to_plc(addr, is_coil=True) + assert result == (bank, index) + + @pytest.mark.parametrize( + "bank,index", + [ + ("DS", 1), + ("DS", 4500), + ("DD", 1), + ("DD", 1000), + ("DH", 1), + ("DH", 500), + ("DF", 1), + ("DF", 500), + ("TXT", 1), + ("TXT", 999), + ("TD", 1), + ("TD", 500), + ("CTD", 1), + ("CTD", 250), + ("SD", 1), + ("SD", 1000), + ("XD", 0), + ("XD", 2), + ("XD", 16), + ("YD", 0), + ("YD", 2), + ("YD", 16), + ], + ) + def test_register_round_trip(self, bank: str, index: int): + addr, _ = plc_to_modbus(bank, index) + result = modbus_to_plc(addr, is_coil=False) + assert result == (bank, index) + + +# ============================================================================== +# Pack / Unpack +# ============================================================================== + + +class TestPackValue: + """Tests for pack_value.""" + + # --- INT (int16 signed) --- + + def test_int_zero(self): + assert pack_value(0, DataType.INT) == [0] + + def test_int_positive(self): + assert pack_value(1, DataType.INT) == [1] + + def test_int_negative(self): + regs = pack_value(-1, DataType.INT) + assert regs == [0xFFFF] + + def test_int_max(self): + assert pack_value(32767, DataType.INT) == [32767] + + def test_int_min(self): + regs = pack_value(-32768, DataType.INT) + assert regs == [0x8000] + + # --- HEX (uint16) --- + + def test_hex_zero(self): + assert pack_value(0, DataType.HEX) == [0] + + def test_hex_ffff(self): + assert pack_value(0xFFFF, DataType.HEX) == [0xFFFF] + + def test_hex_abcd(self): + assert pack_value(0xABCD, DataType.HEX) == [0xABCD] + + # --- INT2 (int32 signed) --- + + def test_int2_zero(self): + assert pack_value(0, DataType.INT2) == [0, 0] + + def test_int2_positive(self): + regs = pack_value(100000, DataType.INT2) + raw = struct.pack(" bool | int | float | str: + if data_type == DataType.BIT: + return False + if data_type == DataType.FLOAT: + return 0.0 + if data_type == DataType.TXT: + return "\x00" + return 0 + + +class _FakeAccessor: + def __init__(self, client: _FakeClickClient, bank: str) -> None: + self._client = client + self._bank = bank + + async def read(self, start: int, end: int | None = None) -> ModbusResponse: + self._client.read_calls.append((self._bank, start, end)) + if self._client.slow_read_s > 0: + await asyncio.sleep(self._client.slow_read_s) + if self._client.read_error_bank == self._bank: + raise OSError(f"read failed for {self._bank}") + + stop = start if end is None else end + payload: dict[str, bool | int | float | str] = {} + for index in range(start, stop + 1): + address = format_address_display(self._bank, index) + payload[address] = self._client.data.get( + address, + _default_for_data_type(BANKS[self._bank].data_type), + ) + return ModbusResponse(payload) + + async def write( + self, + start: int, + data: bool | int | float | str | list[bool] | list[int] | list[float] | list[str], + ) -> None: + self._client.write_calls.append((self._bank, start, data)) + if self._client.write_error_bank == self._bank: + raise OSError(f"write failed for {self._bank}") + + if isinstance(data, list): + for offset, value in enumerate(data): + address = format_address_display(self._bank, start + offset) + self._client.data[address] = value + return + + address = format_address_display(self._bank, start) + self._client.data[address] = data + + +class _FakeClickClient: + connect_error_hosts: set[str] = set() + default_read_error_bank: str | None = None + default_write_error_bank: str | None = None + default_slow_read_s: float = 0.0 + instances: list[_FakeClickClient] = [] + + def __init__( + self, + host: str, + port: int = 502, + tags: dict[str, object] | None = None, + timeout: int = 1, + device_id: int = 1, + reconnect_delay: float = 0.0, + reconnect_delay_max: float = 0.0, + ) -> None: + del tags + del timeout + del device_id + self.host = host + self.port = port + self.reconnect_delay = reconnect_delay + self.reconnect_delay_max = reconnect_delay_max + self._client = _FakeConn() + self.data: dict[str, bool | int | float | str] = {} + self.read_calls: list[tuple[str, int, int | None]] = [] + self.write_calls: list[tuple[str, int, object]] = [] + self.read_error_bank = _FakeClickClient.default_read_error_bank + self.write_error_bank = _FakeClickClient.default_write_error_bank + self.slow_read_s = _FakeClickClient.default_slow_read_s + self._accessors: dict[str, _FakeAccessor] = {} + _FakeClickClient.instances.append(self) + + async def __aenter__(self) -> _FakeClickClient: + if self.host in self.connect_error_hosts: + raise OSError(f"connect failed for {self.host}:{self.port}") + self._client.connected = True + return self + + async def __aexit__(self, *args: object) -> None: + del args + self._client.connected = False + + def _get_accessor(self, bank: str) -> _FakeAccessor: + if bank not in self._accessors: + self._accessors[bank] = _FakeAccessor(self, bank) + return self._accessors[bank] + + +@dataclass +class _FakeConn: + connected: bool = False + + +@pytest.fixture(autouse=True) +def _reset_fake(): + _FakeClickClient.connect_error_hosts = set() + _FakeClickClient.default_read_error_bank = None + _FakeClickClient.default_write_error_bank = None + _FakeClickClient.default_slow_read_s = 0.0 + _FakeClickClient.instances = [] + + +@pytest.fixture +def service(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("pyclickplc.modbus_service.ClickClient", _FakeClickClient) + svc = ModbusService(poll_interval_s=0.03) + try: + yield svc + finally: + svc.disconnect() + + +def _wait_for(predicate, *, timeout: float = 1.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + if predicate(): + return + time.sleep(0.01) + raise AssertionError("Condition not met before timeout") + + +def _service_threads() -> list[threading.Thread]: + return [ + t for t in threading.enumerate() if t.name == "pyclickplc-modbus-service" and t.is_alive() + ] + + +class TestLifecycleState: + def test_reconnect_config_validation(self): + with pytest.raises(ValueError): + ReconnectConfig(delay_s=-0.1) + with pytest.raises(ValueError): + ReconnectConfig(max_delay_s=-0.1) + with pytest.raises(ValueError): + ReconnectConfig(delay_s=2.0, max_delay_s=1.0) + + def test_connect_disconnect_state_transitions(self, service: ModbusService): + states: list[ConnectionState] = [] + errors: list[Exception | None] = [] + + state_service = ModbusService( + poll_interval_s=0.03, + on_state=lambda s, e: (states.append(s), errors.append(e)), + ) + try: + state_service.connect("localhost", 15020) + state_service.disconnect() + finally: + state_service.disconnect() + + assert states == [ + ConnectionState.CONNECTING, + ConnectionState.CONNECTED, + ConnectionState.DISCONNECTED, + ] + assert errors[0] is None + assert errors[1] is None + assert errors[2] is None + + def test_connect_failure_emits_error(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("pyclickplc.modbus_service.ClickClient", _FakeClickClient) + _FakeClickClient.connect_error_hosts = {"bad-host"} + + states: list[ConnectionState] = [] + errors: list[Exception | None] = [] + svc = ModbusService( + poll_interval_s=0.03, + on_state=lambda s, e: (states.append(s), errors.append(e)), + ) + try: + with pytest.raises(OSError): + svc.connect("bad-host", 15020) + finally: + svc.disconnect() + + assert states[0] == ConnectionState.CONNECTING + assert states[1] == ConnectionState.ERROR + assert isinstance(errors[1], OSError) + + def test_connect_reconnect_config_passes_through(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("pyclickplc.modbus_service.ClickClient", _FakeClickClient) + svc = ModbusService( + poll_interval_s=0.03, + reconnect=ReconnectConfig(delay_s=0.25, max_delay_s=2.0), + ) + try: + svc.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + assert fake.reconnect_delay == 0.25 + assert fake.reconnect_delay_max == 2.0 + finally: + svc.disconnect() + + def test_connect_without_reconnect_config_disables_reconnect( + self, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setattr("pyclickplc.modbus_service.ClickClient", _FakeClickClient) + svc = ModbusService(poll_interval_s=0.03) + try: + svc.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + assert fake.reconnect_delay == 0.0 + assert fake.reconnect_delay_max == 0.0 + finally: + svc.disconnect() + + def test_poll_failure_emits_error_once_when_failure_starts( + self, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setattr("pyclickplc.modbus_service.ClickClient", _FakeClickClient) + states: list[ConnectionState] = [] + errors: list[Exception | None] = [] + svc = ModbusService( + poll_interval_s=0.03, + on_state=lambda s, e: (states.append(s), errors.append(e)), + ) + try: + svc.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + fake.read_error_bank = "DS" + svc.set_poll_addresses(["DS1"]) + + _wait_for(lambda: states.count(ConnectionState.ERROR) >= 1) + time.sleep(0.1) + + error_indexes = [i for i, state in enumerate(states) if state == ConnectionState.ERROR] + assert len(error_indexes) == 1 + assert isinstance(errors[error_indexes[0]], OSError) + finally: + svc.disconnect() + + def test_poll_failure_recovery_allows_future_failure_transition( + self, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setattr("pyclickplc.modbus_service.ClickClient", _FakeClickClient) + states: list[ConnectionState] = [] + svc = ModbusService( + poll_interval_s=0.03, + on_state=lambda s, e: states.append(s), + ) + try: + svc.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + svc.set_poll_addresses(["DS1"]) + + fake.read_error_bank = "DS" + _wait_for(lambda: states.count(ConnectionState.ERROR) >= 1) + + fake.read_error_bank = None + _wait_for(lambda: states.count(ConnectionState.CONNECTED) >= 2) + + fake.read_error_bank = "DS" + _wait_for(lambda: states.count(ConnectionState.ERROR) >= 2) + finally: + svc.disconnect() + + def test_disconnect_stops_background_thread(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("pyclickplc.modbus_service.ClickClient", _FakeClickClient) + before = len(_service_threads()) + svc = ModbusService(poll_interval_s=0.03) + try: + svc.connect("localhost", 15020) + _wait_for(lambda: len(_service_threads()) == before + 1) + svc.disconnect() + _wait_for(lambda: len(_service_threads()) == before) + finally: + svc.disconnect() + + def test_reconnect_after_disconnect_restarts_loop(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("pyclickplc.modbus_service.ClickClient", _FakeClickClient) + svc = ModbusService(poll_interval_s=0.03) + try: + svc.connect("localhost", 15020) + svc.disconnect() + svc.connect("localhost", 15020) + finally: + svc.disconnect() + + assert len(_FakeClickClient.instances) == 2 + + +class TestPollConfiguration: + def test_set_poll_addresses_replaces_and_clear_stops(self, service: ModbusService): + values_seen: list[ModbusResponse] = [] + callback_event = threading.Event() + + callback_service = ModbusService( + poll_interval_s=0.03, + on_values=lambda r: (values_seen.append(r), callback_event.set()), + ) + try: + callback_service.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + fake.data["DS1"] = 11 + fake.data["DS2"] = 22 + fake.data["DS3"] = 33 + + callback_service.set_poll_addresses(["ds1", "ds2"]) + _wait_for(lambda: len(values_seen) >= 1) + assert set(values_seen[-1].keys()) == {"DS1", "DS2"} + + callback_service.set_poll_addresses(["ds3"]) + _wait_for(lambda: any(set(v.keys()) == {"DS3"} for v in values_seen)) + + before = len(values_seen) + callback_service.clear_poll_addresses() + time.sleep(0.09) + assert len(values_seen) == before + finally: + callback_service.disconnect() + + def test_stop_polling_pauses_until_reconfigured(self, service: ModbusService): + values_seen: list[ModbusResponse] = [] + + callback_service = ModbusService( + poll_interval_s=0.03, + on_values=lambda r: values_seen.append(r), + ) + try: + callback_service.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + fake.data["DS1"] = 1 + fake.data["DS2"] = 2 + + callback_service.set_poll_addresses(["DS1"]) + _wait_for(lambda: len(values_seen) >= 1) + + before = len(values_seen) + callback_service.stop_polling() + time.sleep(0.09) + assert len(values_seen) == before + + callback_service.set_poll_addresses(["DS2"]) + _wait_for(lambda: any("DS2" in sample for sample in values_seen)) + finally: + callback_service.disconnect() + + +class TestReadBehavior: + def test_read_returns_modbus_response_with_canonical_keys(self, service: ModbusService): + service.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + fake.data["DS1"] = 42 + + result = service.read(["ds1"]) + assert isinstance(result, ModbusResponse) + assert result == {"DS1": 42} + + def test_read_invalid_address_raises_value_error(self, service: ModbusService): + service.connect("localhost", 15020) + with pytest.raises(ValueError): + service.read(["not-an-address"]) + + def test_read_transport_error_raises_oserror(self, service: ModbusService): + service.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + fake.read_error_bank = "DS" + with pytest.raises(OSError): + service.read(["DS1"]) + + +class TestWriteBehavior: + def test_write_accepts_mapping_and_iterable(self, service: ModbusService): + service.connect("localhost", 15020) + + result_map = service.write({"ds1": 1, "ds2": 2}) + assert [r["ok"] for r in result_map] == [True, True] + assert [r["address"] for r in result_map] == ["DS1", "DS2"] + + result_iter = service.write([("DS3", 3), ("DS4", 4)]) + assert [r["ok"] for r in result_iter] == [True, True] + assert [r["address"] for r in result_iter] == ["DS3", "DS4"] + + def test_write_non_writable_address_fails_per_item(self, service: ModbusService): + service.connect("localhost", 15020) + result = service.write([("X1", True)]) + assert result == [{"address": "X001", "ok": False, "error": "X1 is not writable."}] + + def test_write_invalid_value_fails_and_service_continues(self, service: ModbusService): + service.connect("localhost", 15020) + result = service.write([("DS1", "bad"), ("DS2", 5)]) + assert result[0]["ok"] is False + assert "DS1 value must be int" in cast(str, result[0]["error"]) + assert result[1] == {"address": "DS2", "ok": True, "error": None} + + def test_write_transport_failure_marks_batch_failed(self, service: ModbusService): + service.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + fake.write_error_bank = "DS" + + result = service.write([("DS1", 10), ("DS2", 20)]) + assert result[0]["ok"] is False + assert result[1]["ok"] is False + assert "write failed for DS" in cast(str, result[0]["error"]) + + +class TestBatchingHeuristics: + def test_contiguous_writes_are_batched_deterministically(self, service: ModbusService): + service.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + + service.write([("DS1", 1), ("DS2", 2), ("DS3", 3), ("DF1", 1.0), ("DF2", 2.0)]) + + assert fake.write_calls == [ + ("DS", 1, [1, 2, 3]), + ("DF", 1, [1.0, 2.0]), + ] + + def test_sparse_bank_reads_do_not_bridge_invalid_gaps(self, service: ModbusService): + service.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + + service.read(["X001", "X016", "X021", "X022"]) + + x_reads = [call for call in fake.read_calls if call[0] == "X"] + assert x_reads == [ + ("X", 1, None), + ("X", 16, None), + ("X", 21, 22), + ] + + def test_width_two_banks_honor_register_limits(self, service: ModbusService): + service.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + addresses = [f"DF{i}" for i in range(1, 64)] + + service.read(addresses) + + df_reads = [call for call in fake.read_calls if call[0] == "DF"] + assert df_reads == [ + ("DF", 1, 62), + ("DF", 63, None), + ] + + +class TestThreadSafety: + def test_concurrent_set_poll_addresses_during_poll_is_safe(self, service: ModbusService): + service.connect("localhost", 15020) + service.set_poll_addresses(["DS1", "DS2"]) + errors: list[Exception] = [] + + def worker(values: Iterable[str]) -> None: + try: + for _ in range(20): + service.set_poll_addresses(values) + except Exception as exc: # pragma: no cover - safety capture + errors.append(exc) + + t1 = threading.Thread(target=worker, args=(["DS1"],)) + t2 = threading.Thread(target=worker, args=(["DS2"],)) + t1.start() + t2.start() + t1.join() + t2.join() + + assert errors == [] + + def test_disconnect_during_active_poll_exits_cleanly(self, service: ModbusService): + service.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + fake.slow_read_s = 0.2 + service.set_poll_addresses(["DS1"]) + + time.sleep(0.05) + service.disconnect() + # second disconnect should be a no-op + service.disconnect() + + def test_sync_calls_inside_callbacks_fail_fast(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("pyclickplc.modbus_service.ClickClient", _FakeClickClient) + callback_errors: list[Exception] = [] + callback_called = threading.Event() + holder: dict[str, ModbusService] = {} + + def on_values(_values: ModbusResponse) -> None: + try: + holder["svc"].read(["DS1"]) + except Exception as exc: # pragma: no cover - callback behavior + callback_errors.append(exc) + finally: + callback_called.set() + + svc = ModbusService(poll_interval_s=0.03, on_values=on_values) + holder["svc"] = svc + try: + svc.connect("localhost", 15020) + svc.set_poll_addresses(["DS1"]) + _wait_for(callback_called.is_set) + finally: + svc.disconnect() + + assert len(callback_errors) == 1 + assert isinstance(callback_errors[0], RuntimeError) diff --git a/tests/test_nicknames.py b/tests/test_nicknames.py new file mode 100644 index 0000000..f209bc1 --- /dev/null +++ b/tests/test_nicknames.py @@ -0,0 +1,243 @@ +"""Tests for pyclickplc.nicknames — CSV read/write for address data.""" + +import pytest + +from pyclickplc.addresses import AddressRecord, get_addr_key +from pyclickplc.banks import DataType +from pyclickplc.nicknames import ( + CSV_COLUMNS, + DATA_TYPE_CODE_TO_STR, + DATA_TYPE_STR_TO_CODE, + AddressRecordMap, + read_csv, + write_csv, +) + + +class TestConstants: + """Tests for CSV constants.""" + + def test_csv_columns(self): + assert CSV_COLUMNS == [ + "Address", + "Data Type", + "Nickname", + "Initial Value", + "Retentive", + "Address Comment", + ] + + def test_data_type_str_to_code(self): + assert DATA_TYPE_STR_TO_CODE["BIT"] == 0 + assert DATA_TYPE_STR_TO_CODE["INT"] == 1 + assert DATA_TYPE_STR_TO_CODE["TEXT"] == 6 + assert DATA_TYPE_STR_TO_CODE["TXT"] == 6 + + def test_data_type_code_to_str(self): + assert DATA_TYPE_CODE_TO_STR[0] == "BIT" + assert DATA_TYPE_CODE_TO_STR[1] == "INT" + assert DATA_TYPE_CODE_TO_STR[6] == "TEXT" + + +class TestReadCsv: + """Tests for read_csv function.""" + + def test_read_basic(self, tmp_path): + csv_path = tmp_path / "test.csv" + csv_path.write_text( + "Address,Data Type,Nickname,Initial Value,Retentive,Address Comment\n" + 'X001,BIT,"Input1",0,No,"First input"\n' + 'DS1,INT,"Temp",100,Yes,"Temperature"\n', + encoding="utf-8", + ) + + records = read_csv(csv_path) + assert isinstance(records, AddressRecordMap) + assert len(records) == 2 + + # Find the X001 record + x_records = [r for r in records.values() if r.memory_type == "X"] + assert len(x_records) == 1 + assert x_records[0].nickname == "Input1" + assert x_records[0].comment == "First input" + assert x_records[0].data_type == DataType.BIT + assert x_records[0].retentive is False + + # Find the DS1 record + ds_records = [r for r in records.values() if r.memory_type == "DS"] + assert len(ds_records) == 1 + assert ds_records[0].nickname == "Temp" + assert ds_records[0].retentive is True + + # Existing int-key access remains intact + assert records[get_addr_key("X", 1)].nickname == "Input1" + assert records[get_addr_key("DS", 1)].nickname == "Temp" + + def test_read_addr_lookup_normalized(self, tmp_path): + csv_path = tmp_path / "test.csv" + csv_path.write_text( + "Address,Data Type,Nickname,Initial Value,Retentive,Address Comment\n" + 'X001,BIT,"Input1",0,No,""\n' + 'DS1,INT,"Temp",0,Yes,""\n', + encoding="utf-8", + ) + records = read_csv(csv_path) + assert records.addr["x1"].nickname == "Input1" + assert records.addr["X001"].nickname == "Input1" + assert records.addr["ds1"].nickname == "Temp" + + def test_read_tag_lookup_case_insensitive(self, tmp_path): + csv_path = tmp_path / "test.csv" + csv_path.write_text( + "Address,Data Type,Nickname,Initial Value,Retentive,Address Comment\n" + 'X001,BIT,"MyTag",0,No,""\n', + encoding="utf-8", + ) + records = read_csv(csv_path) + assert records.tag["MyTag"].display_address == "X001" + assert records.tag["mytag"].display_address == "X001" + + def test_read_tag_lookup_excludes_empty_nickname(self, tmp_path): + csv_path = tmp_path / "test.csv" + csv_path.write_text( + "Address,Data Type,Nickname,Initial Value,Retentive,Address Comment\n" + 'X001,BIT,"",0,No,""\n', + encoding="utf-8", + ) + records = read_csv(csv_path) + with pytest.raises(KeyError): + _ = records.tag[""] + + def test_read_empty_rows_skipped(self, tmp_path): + csv_path = tmp_path / "test.csv" + csv_path.write_text( + "Address,Data Type,Nickname,Initial Value,Retentive,Address Comment\n" + ",BIT,,0,No,\n" + 'X001,BIT,"Input1",0,No,""\n', + encoding="utf-8", + ) + + records = read_csv(csv_path) + assert len(records) == 1 + + def test_read_invalid_address_skipped(self, tmp_path): + csv_path = tmp_path / "test.csv" + csv_path.write_text( + "Address,Data Type,Nickname,Initial Value,Retentive,Address Comment\n" + 'INVALID,BIT,"Bad",0,No,""\n' + 'X001,BIT,"Good",0,No,""\n', + encoding="utf-8", + ) + + records = read_csv(csv_path) + assert len(records) == 1 + + def test_read_duplicate_nickname_case_insensitive_raises(self, tmp_path): + csv_path = tmp_path / "test.csv" + csv_path.write_text( + "Address,Data Type,Nickname,Initial Value,Retentive,Address Comment\n" + 'X001,BIT,"Pump",0,No,""\n' + 'X002,BIT,"pump",0,No,""\n', + encoding="utf-8", + ) + with pytest.raises(ValueError, match="Case-insensitive duplicate nickname"): + read_csv(csv_path) + + def test_tag_lookup_rejects_case_collisions_after_mutation(self): + records = AddressRecordMap( + { + 1: AddressRecord(memory_type="X", address=1, nickname="Pump"), + 2: AddressRecord(memory_type="X", address=2, nickname="pump"), + } + ) + with pytest.raises(ValueError, match="duplicate tag nickname"): + _ = records.tag["pump"] + + +class TestWriteCsv: + """Tests for write_csv function.""" + + def test_write_basic(self, tmp_path): + csv_path = tmp_path / "test.csv" + records = { + 1: AddressRecord(memory_type="X", address=1, nickname="Input1", comment="First input"), + } + + count = write_csv(csv_path, records) + assert count == 1 + + content = csv_path.read_text(encoding="utf-8") + lines = content.strip().split("\n") + assert lines[0] == ",".join(CSV_COLUMNS) + assert '"Input1"' in lines[1] + assert '"First input"' in lines[1] + + def test_write_skips_empty_records(self, tmp_path): + csv_path = tmp_path / "test.csv" + records = { + 1: AddressRecord(memory_type="X", address=1), # No content + 2: AddressRecord(memory_type="X", address=2, nickname="HasNick"), + } + + count = write_csv(csv_path, records) + assert count == 1 # Only one record has content + + def test_write_sorted_by_memory_type(self, tmp_path): + csv_path = tmp_path / "test.csv" + # DS comes after X in MEMORY_TYPE_BASES ordering + records = { + 100_000_001: AddressRecord(memory_type="DS", address=1, nickname="DS_Nick"), + 1: AddressRecord(memory_type="X", address=1, nickname="X_Nick"), + } + + write_csv(csv_path, records) + + content = csv_path.read_text(encoding="utf-8") + lines = content.strip().split("\n") + # X should come before DS + assert "X001" in lines[1] + assert "DS1" in lines[2] + + +class TestRoundTrip: + """Tests for write then read round-trip.""" + + def test_roundtrip(self, tmp_path): + csv_path = tmp_path / "roundtrip.csv" + original = { + 1: AddressRecord( + memory_type="X", + address=1, + nickname="Motor1", + comment="Main motor", + data_type=DataType.BIT, + retentive=False, + ), + 100_663_297: AddressRecord( + memory_type="DS", + address=1, + nickname="Speed", + comment="Motor speed", + initial_value="500", + data_type=DataType.INT, + retentive=True, + ), + } + + write_csv(csv_path, original) + loaded = read_csv(csv_path) + + assert len(loaded) == 2 + + # Check X001 + x_records = [r for r in loaded.values() if r.memory_type == "X"] + assert len(x_records) == 1 + assert x_records[0].nickname == "Motor1" + assert x_records[0].comment == "Main motor" + + # Check DS1 + ds_records = [r for r in loaded.values() if r.memory_type == "DS"] + assert len(ds_records) == 1 + assert ds_records[0].nickname == "Speed" + assert ds_records[0].comment == "Motor speed" + assert ds_records[0].retentive is True diff --git a/tests/test_placeholder.py b/tests/test_placeholder.py deleted file mode 100644 index dbdf392..0000000 --- a/tests/test_placeholder.py +++ /dev/null @@ -1,3 +0,0 @@ -# Placeholder for an empty build. -def test_placeholder(): - assert True diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..170778d --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,616 @@ +"""Tests for pyclickplc.server — Modbus TCP server for CLICK PLCs.""" + +from __future__ import annotations + +from typing import cast +from unittest.mock import MagicMock + +import pytest +from pymodbus.server import ModbusTcpServer + +from pyclickplc.banks import DataType +from pyclickplc.modbus import modbus_to_plc_register, pack_value, plc_to_modbus +from pyclickplc.server import ( + ClickServer, + MemoryDataProvider, + _ClickDeviceContext, +) + + +def _as_float(value: object) -> float: + assert isinstance(value, (int, float)) + return float(value) + + +# ============================================================================== +# MemoryDataProvider +# ============================================================================== + + +class TestMemoryDataProvider: + """Tests for MemoryDataProvider defaults, set/get, and normalization.""" + + def test_default_bool(self): + p = MemoryDataProvider() + assert p.get("C1") is False + + def test_default_int16(self): + p = MemoryDataProvider() + assert p.get("DS1") == 0 + + def test_default_int32(self): + p = MemoryDataProvider() + assert p.get("DD1") == 0 + + def test_default_float(self): + p = MemoryDataProvider() + assert p.get("DF1") == 0.0 + + def test_default_hex(self): + p = MemoryDataProvider() + assert p.get("DH1") == 0 + + def test_default_txt(self): + p = MemoryDataProvider() + assert p.get("TXT1") == "\x00" + + def test_set_get_round_trip(self): + p = MemoryDataProvider() + p.set("DF1", 3.14) + assert p.get("DF1") == 3.14 + + def test_write_read_round_trip(self): + p = MemoryDataProvider() + p.write("DS100", 42) + assert p.read("DS100") == 42 + + def test_set_then_read(self): + p = MemoryDataProvider() + p.set("C1", True) + assert p.read("C1") is True + + def test_bulk_set(self): + p = MemoryDataProvider() + p.bulk_set({"DF1": 1.0, "DF2": 2.0, "DS1": 42}) + assert p.get("DF1") == 1.0 + assert p.get("DF2") == 2.0 + assert p.get("DS1") == 42 + + def test_address_normalization(self): + """Case-insensitive address normalization.""" + p = MemoryDataProvider() + p.set("df1", 1.0) + assert p.get("DF1") == 1.0 + + def test_address_normalization_x(self): + p = MemoryDataProvider() + p.set("x1", True) + assert p.get("X001") is True + + def test_address_normalization_y(self): + p = MemoryDataProvider() + p.set("y1", True) + assert p.get("Y001") is True + + def test_overwrite(self): + p = MemoryDataProvider() + p.set("DS1", 10) + p.set("DS1", 20) + assert p.get("DS1") == 20 + + def test_sc_default(self): + p = MemoryDataProvider() + assert p.get("SC1") is False + + def test_sd_default(self): + p = MemoryDataProvider() + assert p.get("SD1") == 0 + + def test_td_default(self): + p = MemoryDataProvider() + assert p.get("TD1") == 0 + + def test_ctd_default(self): + p = MemoryDataProvider() + assert p.get("CTD1") == 0 + + def test_xd_default(self): + p = MemoryDataProvider() + assert p.get("XD0") == 0 + + def test_yd_default(self): + p = MemoryDataProvider() + assert p.get("YD0") == 0 + + def test_set_rejects_out_of_range_int(self): + p = MemoryDataProvider() + with pytest.raises(ValueError, match="DS1 value must be int"): + p.set("DS1", 999999999999) + + def test_set_rejects_non_numeric_float(self): + p = MemoryDataProvider() + with pytest.raises(ValueError, match="DF1 value must be a finite float32"): + p.set("DF1", "abc") + + def test_set_rejects_nan_float(self): + p = MemoryDataProvider() + with pytest.raises(ValueError, match="DF1 value must be a finite float32"): + p.set("DF1", float("nan")) + + def test_set_rejects_inf_float(self): + p = MemoryDataProvider() + with pytest.raises(ValueError, match="DF1 value must be a finite float32"): + p.set("DF1", float("inf")) + + def test_set_rejects_invalid_txt(self): + p = MemoryDataProvider() + with pytest.raises( + ValueError, match="TXT1 TXT value must be blank or a single ASCII character" + ): + p.set("TXT1", "AB") + + def test_set_rejects_word_underflow(self): + p = MemoryDataProvider() + with pytest.raises(ValueError, match="DH1 must be WORD"): + p.set("DH1", -1) + + def test_set_rejects_bool_for_numeric(self): + p = MemoryDataProvider() + with pytest.raises(ValueError, match="DS1 value must be int"): + p.set("DS1", True) + + +# ============================================================================== +# modbus_to_plc_register +# ============================================================================== + + +class TestModbusToPlcRegister: + """Tests for extended reverse register mapping.""" + + def test_ds1(self): + assert modbus_to_plc_register(0) == ("DS", 1, 0) + + def test_ds4500(self): + assert modbus_to_plc_register(4499) == ("DS", 4500, 0) + + def test_dd1_first_reg(self): + assert modbus_to_plc_register(16384) == ("DD", 1, 0) + + def test_dd1_second_reg(self): + """Unlike modbus_to_plc, this returns the mid-value register.""" + assert modbus_to_plc_register(16385) == ("DD", 1, 1) + + def test_dd2(self): + assert modbus_to_plc_register(16386) == ("DD", 2, 0) + + def test_df1_first_reg(self): + assert modbus_to_plc_register(28672) == ("DF", 1, 0) + + def test_df1_second_reg(self): + assert modbus_to_plc_register(28673) == ("DF", 1, 1) + + def test_df2(self): + assert modbus_to_plc_register(28674) == ("DF", 2, 0) + + def test_dh1(self): + assert modbus_to_plc_register(24576) == ("DH", 1, 0) + + def test_txt1(self): + assert modbus_to_plc_register(36864) == ("TXT", 1, 0) + + def test_txt101(self): + assert modbus_to_plc_register(36914) == ("TXT", 101, 0) + + def test_td1(self): + assert modbus_to_plc_register(45056) == ("TD", 1, 0) + + def test_ctd1_first(self): + assert modbus_to_plc_register(49152) == ("CTD", 1, 0) + + def test_ctd1_second(self): + assert modbus_to_plc_register(49153) == ("CTD", 1, 1) + + def test_sd1(self): + assert modbus_to_plc_register(61440) == ("SD", 1, 0) + + def test_xd0(self): + assert modbus_to_plc_register(57344) == ("XD", 0, 0) + + def test_xd_mdb1(self): + """XD MDB index 1 (XD0u) is now addressable.""" + assert modbus_to_plc_register(57345) == ("XD", 1, 0) + + def test_yd0(self): + assert modbus_to_plc_register(57856) == ("YD", 0, 0) + + def test_unmapped(self): + assert modbus_to_plc_register(5000) is None + + +# ============================================================================== +# _ClickDeviceContext — coil reads +# ============================================================================== + + +class TestContextCoilReads: + """Test _ClickDeviceContext.getValues for coils.""" + + def test_read_single_coil_c1(self): + p = MemoryDataProvider() + p.set("C1", True) + ctx = _ClickDeviceContext(p) + result = ctx.getValues(1, 16384, 1) + assert result == [True] + + def test_read_coil_range_c1_c5(self): + p = MemoryDataProvider() + for i in range(1, 6): + p.set(f"C{i}", i % 2 == 1) + ctx = _ClickDeviceContext(p) + result = ctx.getValues(1, 16384, 5) + assert result == [True, False, True, False, True] + + def test_read_sparse_x001(self): + p = MemoryDataProvider() + p.set("X001", True) + ctx = _ClickDeviceContext(p) + result = ctx.getValues(2, 0, 1) + assert result == [True] + + def test_read_unmapped_coil(self): + p = MemoryDataProvider() + ctx = _ClickDeviceContext(p) + result = ctx.getValues(1, 10000, 1) + assert result == [False] + + def test_read_sparse_gap(self): + """Coil 48 is a sparse gap -> False.""" + p = MemoryDataProvider() + ctx = _ClickDeviceContext(p) + result = ctx.getValues(2, 48, 1) + assert result == [False] + + +# ============================================================================== +# _ClickDeviceContext — register reads +# ============================================================================== + + +class TestContextRegisterReads: + """Test _ClickDeviceContext.getValues for registers.""" + + def test_read_ds1(self): + p = MemoryDataProvider() + p.set("DS1", 42) + ctx = _ClickDeviceContext(p) + result = ctx.getValues(3, 0, 1) + # DS1=42 -> pack_value(42, INT) = [42] + assert result == [42] + + def test_read_df1(self): + p = MemoryDataProvider() + p.set("DF1", 3.14) + ctx = _ClickDeviceContext(p) + result = ctx.getValues(3, 28672, 2) + # Verify round-trip + import struct + + raw = struct.pack(" None: + self._active = active + self.active_connections: dict[str, object] = {} + + def is_active(self) -> bool: + return self._active + + +class _FakeTransport: + def __init__(self, peername: object = None) -> None: + self._peername = peername + + def get_extra_info(self, key: str) -> object: + if key == "peername": + return self._peername + return None + + +class _FakeConnection: + def __init__(self, peername: object = None) -> None: + self.transport = _FakeTransport(peername) + self.close = MagicMock() + + +class TestClickServerRuntimeControls: + def test_is_running_false_before_start(self): + server = ClickServer(MemoryDataProvider()) + assert server.is_running() is False + + def test_is_running_true_when_server_active(self): + server = ClickServer(MemoryDataProvider()) + server._server = cast(ModbusTcpServer, _FakeServer(active=True)) + assert server.is_running() is True + + def test_is_running_false_when_server_inactive(self): + server = ClickServer(MemoryDataProvider()) + server._server = cast(ModbusTcpServer, _FakeServer(active=False)) + assert server.is_running() is False + + def test_list_clients_empty_when_not_started(self): + server = ClickServer(MemoryDataProvider()) + assert server.list_clients() == [] + + def test_list_clients_includes_peer(self): + server = ClickServer(MemoryDataProvider()) + fake = _FakeServer(active=True) + fake.active_connections["abc"] = _FakeConnection(("127.0.0.1", 5020)) + fake.active_connections["def"] = _FakeConnection(None) + server._server = cast(ModbusTcpServer, fake) + + clients = server.list_clients() + assert len(clients) == 2 + assert clients[0].client_id == "abc" + assert clients[0].peer == "127.0.0.1:5020" + assert clients[1].client_id == "def" + assert clients[1].peer == "unknown" + + def test_disconnect_client_unknown_returns_false(self): + server = ClickServer(MemoryDataProvider()) + fake = _FakeServer(active=True) + server._server = cast(ModbusTcpServer, fake) + assert server.disconnect_client("missing") is False + + def test_disconnect_client_known_returns_true(self): + server = ClickServer(MemoryDataProvider()) + fake = _FakeServer(active=True) + connection = _FakeConnection(("127.0.0.1", 1234)) + fake.active_connections["known"] = connection + server._server = cast(ModbusTcpServer, fake) + + assert server.disconnect_client("known") is True + connection.close.assert_called_once_with() + + def test_disconnect_all_clients(self): + server = ClickServer(MemoryDataProvider()) + fake = _FakeServer(active=True) + c1 = _FakeConnection(("127.0.0.1", 1111)) + c2 = _FakeConnection(("127.0.0.1", 2222)) + fake.active_connections["a"] = c1 + fake.active_connections["b"] = c2 + server._server = cast(ModbusTcpServer, fake) + + count = server.disconnect_all_clients() + assert count == 2 + c1.close.assert_called_once_with() + c2.close.assert_called_once_with() diff --git a/tests/test_server_tui.py b/tests/test_server_tui.py new file mode 100644 index 0000000..e6584ec --- /dev/null +++ b/tests/test_server_tui.py @@ -0,0 +1,183 @@ +"""Tests for pyclickplc.server_tui.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import cast + +from pyclickplc.server import ClickServer, ServerClientInfo +from pyclickplc.server_tui import run_server_tui + + +@dataclass +class _FakeServer: + host: str = "127.0.0.1" + port: int = 5020 + running: bool = False + clients: list[ServerClientInfo] | None = None + disconnect_all_count: int = 0 + disconnect_ok: bool = False + + def __post_init__(self) -> None: + if self.clients is None: + self.clients = [] + self.start_calls = 0 + self.stop_calls = 0 + self.disconnect_all_calls = 0 + self.disconnect_client_calls: list[str] = [] + + def is_running(self) -> bool: + return self.running + + async def start(self) -> None: + self.start_calls += 1 + self.running = True + + async def stop(self) -> None: + self.stop_calls += 1 + self.running = False + + def list_clients(self) -> list[ServerClientInfo]: + return list(self.clients or []) + + def disconnect_all_clients(self) -> int: + self.disconnect_all_calls += 1 + return self.disconnect_all_count + + def disconnect_client(self, client_id: str) -> bool: + self.disconnect_client_calls.append(client_id) + return self.disconnect_ok + + +def _input_from(values: list[str]): + queue = list(values) + + def _input(_: str) -> str: + if not queue: + raise EOFError + return queue.pop(0) + + return _input + + +async def test_run_server_tui_status_help_shutdown(): + server = _FakeServer() + output: list[str] = [] + + await run_server_tui( + cast(ClickServer, server), + input_fn=_input_from(["status", "help", "shutdown"]), + output_fn=output.append, + ) + + assert server.start_calls == 1 + assert server.stop_calls == 1 + assert any("Serving connection at 127.0.0.1:5020" in line for line in output) + assert any("Running: True | Clients: 0" in line for line in output) + assert any("Commands:" in line for line in output) + + +async def test_run_server_tui_clients_empty(): + server = _FakeServer() + output: list[str] = [] + + await run_server_tui( + cast(ClickServer, server), + input_fn=_input_from(["clients", "shutdown"]), + output_fn=output.append, + ) + + assert "No clients connected." in output + + +async def test_run_server_tui_disconnect_all(): + server = _FakeServer(disconnect_all_count=3) + output: list[str] = [] + + await run_server_tui( + cast(ClickServer, server), + input_fn=_input_from(["disconnect all", "shutdown"]), + output_fn=output.append, + ) + + assert server.disconnect_all_calls == 1 + assert "Disconnected 3 client(s)." in output + + +async def test_run_server_tui_disconnect_single_success(): + server = _FakeServer(disconnect_ok=True) + output: list[str] = [] + + await run_server_tui( + cast(ClickServer, server), + input_fn=_input_from(["disconnect abc", "shutdown"]), + output_fn=output.append, + ) + + assert server.disconnect_client_calls == ["abc"] + assert "Disconnected client 'abc'." in output + + +async def test_run_server_tui_disconnect_single_not_found(): + server = _FakeServer(disconnect_ok=False) + output: list[str] = [] + + await run_server_tui( + cast(ClickServer, server), + input_fn=_input_from(["disconnect missing", "shutdown"]), + output_fn=output.append, + ) + + assert server.disconnect_client_calls == ["missing"] + assert "Client 'missing' not found." in output + + +async def test_run_server_tui_unknown_command(): + server = _FakeServer() + output: list[str] = [] + + await run_server_tui( + cast(ClickServer, server), + input_fn=_input_from(["badcmd", "shutdown"]), + output_fn=output.append, + ) + + assert any("Unknown command: badcmd." in line for line in output) + + +async def test_run_server_tui_eof_triggers_stop(): + server = _FakeServer() + output: list[str] = [] + + def _eof(_: str) -> str: + raise EOFError + + await run_server_tui(cast(ClickServer, server), input_fn=_eof, output_fn=output.append) + + assert server.stop_calls == 1 + assert "EOF received, shutting down server." in output + + +async def test_run_server_tui_keyboard_interrupt_triggers_stop(): + server = _FakeServer() + output: list[str] = [] + + def _interrupt(_: str) -> str: + raise KeyboardInterrupt + + await run_server_tui(cast(ClickServer, server), input_fn=_interrupt, output_fn=output.append) + + assert server.stop_calls == 1 + assert "Interrupted, shutting down server." in output + + +async def test_run_server_tui_does_not_restart_running_server(): + server = _FakeServer(running=True) + output: list[str] = [] + + await run_server_tui( + cast(ClickServer, server), input_fn=_input_from(["shutdown"]), output_fn=output.append + ) + + assert server.start_calls == 0 + assert server.stop_calls == 1 diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..7024a38 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,249 @@ +"""Tests for pyclickplc.validation module.""" + +import math + +import pytest + +from pyclickplc.banks import DataType +from pyclickplc.validation import ( + FORBIDDEN_CHARS, + SYSTEM_NICKNAME_TYPES, + assert_runtime_value, + validate_comment, + validate_initial_value, + validate_nickname, +) + +# ============================================================================== +# validate_nickname +# ============================================================================== + + +class TestValidateNickname: + def test_empty_valid(self): + assert validate_nickname("") == (True, "") + + def test_simple_valid(self): + assert validate_nickname("Input1") == (True, "") + + def test_max_length(self): + name = "a" * 24 + assert validate_nickname(name) == (True, "") + + def test_too_long(self): + name = "a" * 25 + valid, error = validate_nickname(name) + assert valid is False + assert "Too long" in error + + def test_starts_with_underscore(self): + valid, error = validate_nickname("_invalid") + assert valid is False + assert "Cannot start with _" in error + + def test_non_system_rejects_leading_underscore(self): + valid, error = validate_nickname("_SystemName") + assert valid is False + assert "Cannot start with _" in error + + def test_system_allows_forbidden_chars(self): + # SC nicknames contain / + assert validate_nickname("Comm/Port_1", system_bank="SC") == (True, "") + # SD nicknames contain ( and ) + assert validate_nickname("_Fixed_Scan_Time(ms)", system_bank="SD") == ( + True, + "", + ) + + def test_non_system_rejects_sc_sd_punctuation(self): + valid, error = validate_nickname("Comm/Port_1") + assert valid is False + assert "Invalid" in error + + def test_x_system_requires_io_prefix(self): + valid, error = validate_nickname("_SystemName", system_bank="X") + assert valid is False + assert "_IO" in error + + def test_x_system_rejects_forbidden_chars(self): + valid, error = validate_nickname("_IO1.Module", system_bank="X") + assert valid is False + assert "Invalid" in error + + def test_x_system_allows_io_style_name(self): + assert validate_nickname("_IO1_Module_Error", system_bank="X") == (True, "") + + def test_system_still_enforces_length_and_reserved(self): + # Length still enforced for system nicknames + valid, error = validate_nickname("_" + "a" * 24, system_bank="SC") + assert valid is False + assert "Too long" in error + + # Reserved keywords still enforced + valid, error = validate_nickname("log", system_bank="SC") + assert valid is False + assert "Reserved" in error + + def test_system_nickname_types_constant(self): + assert SYSTEM_NICKNAME_TYPES == {"SC", "SD", "X"} + + def test_reserved_keywords_case_insensitive(self): + for keyword in ("log", "LOG", "Log", "sin", "SIN", "and", "or", "pi"): + valid, error = validate_nickname(keyword) + assert valid is False, f"{keyword} should be rejected" + assert "Reserved" in error + + def test_forbidden_chars(self): + for char in FORBIDDEN_CHARS: + valid, error = validate_nickname(f"test{char}name") + assert valid is False, f"Char {char!r} should be rejected" + assert "Invalid" in error + + def test_space_allowed(self): + assert validate_nickname("my input") == (True, "") + + def test_no_uniqueness_check(self): + # This function does NOT check uniqueness + assert validate_nickname("duplicate") == (True, "") + + +# ============================================================================== +# validate_comment +# ============================================================================== + + +class TestValidateComment: + def test_empty_valid(self): + assert validate_comment("") == (True, "") + + def test_max_length(self): + comment = "a" * 128 + assert validate_comment(comment) == (True, "") + + def test_too_long(self): + comment = "a" * 129 + valid, error = validate_comment(comment) + assert valid is False + assert "Too long" in error + + def test_block_tags_not_rejected(self): + # Length-only validation -- block tags are NOT rejected here + assert validate_comment("[Block:MyBlock]") == (True, "") + assert validate_comment("[Block:Dup]") == (True, "") + + +# ============================================================================== +# validate_initial_value +# ============================================================================== + + +class TestValidateInitialValue: + def test_empty_valid_all_types(self): + for dt in DataType: + assert validate_initial_value("", dt) == (True, "") + + # --- BIT --- + def test_bit_valid(self): + assert validate_initial_value("0", DataType.BIT) == (True, "") + assert validate_initial_value("1", DataType.BIT) == (True, "") + + def test_bit_invalid(self): + valid, error = validate_initial_value("2", DataType.BIT) + assert valid is False + assert "0 or 1" in error + + # --- INT --- + def test_int_valid(self): + assert validate_initial_value("0", DataType.INT) == (True, "") + assert validate_initial_value("-32768", DataType.INT) == (True, "") + assert validate_initial_value("32767", DataType.INT) == (True, "") + + def test_int_out_of_range(self): + valid, error = validate_initial_value("32768", DataType.INT) + assert valid is False + assert "Range" in error + + def test_int_not_number(self): + valid, error = validate_initial_value("abc", DataType.INT) + assert valid is False + assert "integer" in error + + # --- INT2 --- + def test_int2_valid(self): + assert validate_initial_value("0", DataType.INT2) == (True, "") + assert validate_initial_value("-2147483648", DataType.INT2) == (True, "") + assert validate_initial_value("2147483647", DataType.INT2) == (True, "") + + def test_int2_out_of_range(self): + valid, error = validate_initial_value("2147483648", DataType.INT2) + assert valid is False + assert "Range" in error + + # --- FLOAT --- + def test_float_valid(self): + assert validate_initial_value("0.0", DataType.FLOAT) == (True, "") + assert validate_initial_value("1.5", DataType.FLOAT) == (True, "") + assert validate_initial_value("-1.5E+10", DataType.FLOAT) == (True, "") + + def test_float_invalid(self): + valid, error = validate_initial_value("notanumber", DataType.FLOAT) + assert valid is False + assert "number" in error + + # --- HEX --- + def test_hex_valid(self): + assert validate_initial_value("0000", DataType.HEX) == (True, "") + assert validate_initial_value("FFFF", DataType.HEX) == (True, "") + assert validate_initial_value("1A2B", DataType.HEX) == (True, "") + assert validate_initial_value("ff", DataType.HEX) == (True, "") + + def test_hex_too_long(self): + valid, error = validate_initial_value("12345", DataType.HEX) + assert valid is False + assert "4 hex" in error + + def test_hex_invalid_chars(self): + valid, error = validate_initial_value("GHIJ", DataType.HEX) + assert valid is False + assert "hex" in error + + # --- TXT --- + def test_txt_valid(self): + assert validate_initial_value("A", DataType.TXT) == (True, "") + assert validate_initial_value(" ", DataType.TXT) == (True, "") + + def test_txt_too_long(self): + valid, error = validate_initial_value("AB", DataType.TXT) + assert valid is False + assert "single char" in error + + def test_txt_non_ascii(self): + valid, error = validate_initial_value("\u00e9", DataType.TXT) + assert valid is False + assert "ASCII" in error + + +# ============================================================================== +# assert_runtime_value +# ============================================================================== + + +class TestAssertRuntimeValue: + def test_reject_bool_for_int(self): + with pytest.raises(ValueError, match="DS1 value must be int"): + assert_runtime_value(DataType.INT, True, bank="DS", index=1) + + def test_reject_non_finite_float(self): + with pytest.raises(ValueError, match="DF1 value must be a finite float32"): + assert_runtime_value(DataType.FLOAT, math.nan, bank="DF", index=1) + + def test_reject_overflow_float32(self): + with pytest.raises(ValueError, match="DF1 value must be a finite float32"): + assert_runtime_value(DataType.FLOAT, 1e100, bank="DF", index=1) + + def test_reject_invalid_txt(self): + with pytest.raises(ValueError, match="TXT1 TXT value must be blank or a single ASCII"): + assert_runtime_value(DataType.TXT, "AB", bank="TXT", index=1) + + def test_allow_blank_txt(self): + assert_runtime_value(DataType.TXT, "", bank="TXT", index=1) diff --git a/uv.lock b/uv.lock index cd40ca0..a76ebe6 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,136 @@ version = 1 revision = 3 requires-python = ">=3.11, <4.0" +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backrefs" +version = "6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/e3/bb3a439d5cb255c4774724810ad8073830fac9c9dee123555820c1bcc806/backrefs-6.1.tar.gz", hash = "sha256:3bba1749aafe1db9b915f00e0dd166cba613b6f788ffd63060ac3485dc9be231", size = 7011962, upload-time = "2025-11-15T14:52:08.323Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ee/c216d52f58ea75b5e1841022bbae24438b19834a29b163cb32aa3a2a7c6e/backrefs-6.1-py310-none-any.whl", hash = "sha256:2a2ccb96302337ce61ee4717ceacfbf26ba4efb1d55af86564b8bbaeda39cac1", size = 381059, upload-time = "2025-11-15T14:51:59.758Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9a/8da246d988ded941da96c7ed945d63e94a445637eaad985a0ed88787cb89/backrefs-6.1-py311-none-any.whl", hash = "sha256:e82bba3875ee4430f4de4b6db19429a27275d95a5f3773c57e9e18abc23fd2b7", size = 392854, upload-time = "2025-11-15T14:52:01.194Z" }, + { url = "https://files.pythonhosted.org/packages/37/c9/fd117a6f9300c62bbc33bc337fd2b3c6bfe28b6e9701de336b52d7a797ad/backrefs-6.1-py312-none-any.whl", hash = "sha256:c64698c8d2269343d88947c0735cb4b78745bd3ba590e10313fbf3f78c34da5a", size = 398770, upload-time = "2025-11-15T14:52:02.584Z" }, + { url = "https://files.pythonhosted.org/packages/eb/95/7118e935b0b0bd3f94dfec2d852fd4e4f4f9757bdb49850519acd245cd3a/backrefs-6.1-py313-none-any.whl", hash = "sha256:4c9d3dc1e2e558965202c012304f33d4e0e477e1c103663fd2c3cc9bb18b0d05", size = 400726, upload-time = "2025-11-15T14:52:04.093Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/6296bad135bfafd3254ae3648cd152980a424bd6fed64a101af00cc7ba31/backrefs-6.1-py314-none-any.whl", hash = "sha256:13eafbc9ccd5222e9c1f0bec563e6d2a6d21514962f11e7fc79872fd56cbc853", size = 412584, upload-time = "2025-11-15T14:52:05.233Z" }, + { url = "https://files.pythonhosted.org/packages/02/e3/a4fa1946722c4c7b063cc25043a12d9ce9b4323777f89643be74cef2993c/backrefs-6.1-py39-none-any.whl", hash = "sha256:a9e99b8a4867852cad177a6430e31b0f6e495d65f8c6c134b68c14c3c95bf4b0", size = 381058, upload-time = "2025-11-15T14:52:06.698Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + [[package]] name = "codespell" version = "2.4.1" @@ -29,6 +159,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/af/420b08227bd021f80008520aa20a001120e71042342801d0c782a6615a39/funlog-0.2.1-py3-none-any.whl", hash = "sha256:eed8d206c21ee8dc96137b4df51689470682d4700f6f99a1a6133a0e065f3798", size = 9399, upload-time = "2025-03-28T18:55:43.733Z" }, ] +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "griffe" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffecli" }, + { name = "griffelib" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/94/ee21d41e7eb4f823b94603b9d40f86d3c7fde80eacc2c3c71845476dddaa/griffe-2.0.0-py3-none-any.whl", hash = "sha256:5418081135a391c3e6e757a7f3f156f1a1a746cc7b4023868ff7d5e2f9a980aa", size = 5214, upload-time = "2026-02-09T19:09:44.105Z" }, +] + +[[package]] +name = "griffecli" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "griffelib" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ed/d93f7a447bbf7a935d8868e9617cbe1cadf9ee9ee6bd275d3040fbf93d60/griffecli-2.0.0-py3-none-any.whl", hash = "sha256:9f7cd9ee9b21d55e91689358978d2385ae65c22f307a63fb3269acf3f21e643d", size = 9345, upload-time = "2026-02-09T19:09:42.554Z" }, +] + +[[package]] +name = "griffelib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -38,16 +221,149 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + [[package]] name = "markdown-it-py" -version = "4.0.0" +version = "3.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markdownify" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09", size = 18816, upload-time = "2025-11-16T19:21:18.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ce/f1e3e9d959db134cedf06825fae8d5b294bd368aacdd0831a3975b7c4d55/markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a", size = 15724, upload-time = "2025-11-16T19:21:17.622Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdformat" +version = "0.7.22" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/eb/b5cbf2484411af039a3d4aeb53a5160fae25dd8c84af6a4243bc2f3fedb3/mdformat-0.7.22.tar.gz", hash = "sha256:eef84fa8f233d3162734683c2a8a6222227a229b9206872e6139658d99acb1ea", size = 34610, upload-time = "2025-01-30T18:00:51.418Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/6f/94a7344f6d634fe3563bea8b33bccedee37f2726f7807e9a58440dc91627/mdformat-0.7.22-py3-none-any.whl", hash = "sha256:61122637c9e1d9be1329054f3fa216559f0d1f722b7919b060a8c2a4ae1850e5", size = 34447, upload-time = "2025-01-30T18:00:48.708Z" }, +] + +[[package]] +name = "mdformat-tables" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdformat" }, + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/fc/995ba209096bdebdeb8893d507c7b32b7e07d9a9f2cdc2ec07529947794b/mdformat_tables-1.0.0.tar.gz", hash = "sha256:a57db1ac17c4a125da794ef45539904bb8a9592e80557d525e1f169c96daa2c8", size = 6106, upload-time = "2024-08-23T23:41:33.413Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, + { url = "https://files.pythonhosted.org/packages/2a/37/d78e37d14323da3f607cd1af7daf262cb87fe614a245c15ad03bb03a2706/mdformat_tables-1.0.0-py3-none-any.whl", hash = "sha256:94cd86126141b2adc3b04c08d1441eb1272b36c39146bab078249a41c7240a9a", size = 5104, upload-time = "2024-08-23T23:41:31.863Z" }, ] [[package]] @@ -59,6 +375,161 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + +[[package]] +name = "mkdocs-gen-files" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/35/f26349f7fa18414eb2e25d75a6fa9c7e3186c36e1d227c0b2d785a7bd5c4/mkdocs_gen_files-0.6.0.tar.gz", hash = "sha256:52022dc14dcc0451e05e54a8f5d5e7760351b6701eff816d1e9739577ec5635e", size = 8642, upload-time = "2025-11-23T12:13:22.124Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/ec/72417415563c60ae01b36f0d497f1f4c803972f447ef4fb7f7746d6e07db/mkdocs_gen_files-0.6.0-py3-none-any.whl", hash = "sha256:815af15f3e2dbfda379629c1b95c02c8e6f232edf2a901186ea3b204ab1135b2", size = 8182, upload-time = "2025-11-23T12:13:20.756Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, +] + +[[package]] +name = "mkdocs-llmstxt" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "markdownify" }, + { name = "mdformat" }, + { name = "mdformat-tables" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/f5/4c31cdffa7c09bf48d8c7a50d8342dc100abac98ac4150826bc11afc0c9f/mkdocs_llmstxt-0.5.0.tar.gz", hash = "sha256:b2fa9e6d68df41d7467e948a4745725b6c99434a36b36204857dbd7bb3dfe041", size = 33909, upload-time = "2025-11-20T14:02:24.861Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/2b/82928cc9e8d9269cd79e7ebf015efdc4945e6c646e86ec1d4dba1707f215/mkdocs_llmstxt-0.5.0-py3-none-any.whl", hash = "sha256:753c699913d2d619a9072604b26b6dc9f5fb6d257d9b107857f80c8a0b787533", size = 12040, upload-time = "2025-11-20T14:02:23.483Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/e2/2ffc356cd72f1473d07c7719d82a8f2cbd261666828614ecb95b12169f41/mkdocs_material-9.7.1.tar.gz", hash = "sha256:89601b8f2c3e6c6ee0a918cc3566cb201d40bf37c3cd3c2067e26fadb8cce2b8", size = 4094392, upload-time = "2025-12-18T09:49:00.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/32/ed071cb721aca8c227718cffcf7bd539620e9799bbf2619e90c757bfd030/mkdocs_material-9.7.1-py3-none-any.whl", hash = "sha256:3f6100937d7d731f87f1e3e3b021c97f7239666b9ba1151ab476cabb96c60d5c", size = 9297166, upload-time = "2025-12-18T09:48:56.664Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/62/0dfc5719514115bf1781f44b1d7f2a0923fcc01e9c5d7990e48a05c9ae5d/mkdocstrings-1.0.3.tar.gz", hash = "sha256:ab670f55040722b49bb45865b2e93b824450fb4aef638b00d7acb493a9020434", size = 100946, upload-time = "2026-02-07T14:31:40.973Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/41/1cf02e3df279d2dd846a1bf235a928254eba9006dd22b4a14caa71aed0f7/mkdocstrings-1.0.3-py3-none-any.whl", hash = "sha256:0d66d18430c2201dc7fe85134277382baaa15e6b30979f3f3bdbabd6dbdb6046", size = 35523, upload-time = "2026-02-07T14:31:39.27Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffe" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/84/78243847ad9d5c21d30a2842720425b17e880d99dfe824dee11d6b2149b4/mkdocstrings_python-2.0.2.tar.gz", hash = "sha256:4a32ccfc4b8d29639864698e81cfeb04137bce76bb9f3c251040f55d4b6e1ad8", size = 199124, upload-time = "2026-02-09T15:12:01.543Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/31/7ee938abbde2322e553a2cb5f604cdd1e4728e08bba39c7ee6fae9af840b/mkdocstrings_python-2.0.2-py3-none-any.whl", hash = "sha256:31241c0f43d85a69306d704d5725786015510ea3f3c4bdfdb5a5731d83cdc2b0", size = 104900, upload-time = "2026-02-09T15:12:00.166Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -68,6 +539,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -80,28 +578,48 @@ wheels = [ [[package]] name = "pyclickplc" source = { editable = "." } +dependencies = [ + { name = "pymodbus" }, +] [package.dev-dependencies] dev = [ { name = "codespell" }, { name = "funlog" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "rich" }, { name = "ruff" }, { name = "ty" }, ] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-gen-files" }, + { name = "mkdocs-llmstxt" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, +] [package.metadata] +requires-dist = [{ name = "pymodbus", specifier = ">=3.8" }] [package.metadata.requires-dev] dev = [ { name = "codespell", specifier = ">=2.4.1" }, { name = "funlog", specifier = ">=0.2.0" }, { name = "pytest", specifier = ">=8.3.5" }, + { name = "pytest-asyncio", specifier = ">=1.0" }, { name = "rich", specifier = ">=13.9.4" }, { name = "ruff", specifier = ">=0.11.0" }, { name = "ty", specifier = ">=0.0.14" }, ] +docs = [ + { name = "mkdocs", specifier = ">=1.6.1" }, + { name = "mkdocs-gen-files", specifier = ">=0.5.0" }, + { name = "mkdocs-llmstxt", specifier = ">=0.3.0" }, + { name = "mkdocs-material", specifier = ">=9.6.0" }, + { name = "mkdocstrings", extras = ["python"], specifier = ">=0.29.0" }, +] [[package]] name = "pygments" @@ -112,6 +630,28 @@ 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" }, ] +[[package]] +name = "pymdown-extensions" +version = "10.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/6c/9e370934bfa30e889d12e61d0dae009991294f40055c238980066a7fbd83/pymdown_extensions-10.20.1.tar.gz", hash = "sha256:e7e39c865727338d434b55f1dd8da51febcffcaebd6e1a0b9c836243f660740a", size = 852860, upload-time = "2026-01-24T05:56:56.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/6d/b6ee155462a0156b94312bdd82d2b92ea56e909740045a87ccb98bf52405/pymdown_extensions-10.20.1-py3-none-any.whl", hash = "sha256:24af7feacbca56504b313b7b418c4f5e1317bb5fea60f03d57be7fcc40912aa0", size = 268768, upload-time = "2026-01-24T05:56:54.537Z" }, +] + +[[package]] +name = "pymodbus" +version = "3.11.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/af/bbb716301ab9c60f0702c3cdf72cc0e373286a5648fe16bc4431400489dc/pymodbus-3.11.4.tar.gz", hash = "sha256:6910e385cb6b2f983cd457e9ecee2ff580dbb23cf3d84aefec0845e71edd606a", size = 163422, upload-time = "2025-11-30T10:36:33.717Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/80/84c32440949c77f2c201c38e5fa56fd8f19da31bf24df55bdbdaba67767c/pymodbus-3.11.4-py3-none-any.whl", hash = "sha256:89865929f53bd5e32b4076dde00ee86d9b8afb1686832ed74e69d55df22729c3", size = 166002, upload-time = "2025-11-30T10:36:31.992Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -128,6 +668,113 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + [[package]] name = "rich" version = "14.3.2" @@ -166,6 +813,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, ] +[[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 = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + [[package]] name = "ty" version = "0.0.14" @@ -189,3 +854,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b6/d9/c569c9961760e20e0a4bc008eeb1415754564304fd53997a371b7cf3f864/ty-0.0.14-py3-none-win_amd64.whl", hash = "sha256:e312ff9475522d1a33186657fe74d1ec98e4a13e016d66f5758a452c90ff6409", size = 10437980, upload-time = "2026-01-27T00:57:36.422Z" }, { url = "https://files.pythonhosted.org/packages/ad/0c/186829654f5bfd9a028f6648e9caeb11271960a61de97484627d24443f91/ty-0.0.14-py3-none-win_arm64.whl", hash = "sha256:b6facdbe9b740cb2c15293a1d178e22ffc600653646452632541d01c36d5e378", size = 9885831, upload-time = "2026-01-27T00:57:49.747Z" }, ] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, +]