diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index eb5d824..8a89d3f 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -7,7 +7,8 @@
"WebFetch(domain:ragardner.github.io)",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(uv run pytest:*)",
- "Bash(uv run repomapper:*)"
+ "Bash(uv run:*)",
+ "Bash(do uv:*)"
]
}
}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b2dfe2d..dc43204 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,16 +31,16 @@ jobs:
# https://docs.astral.sh/uv/guides/integration/github/
- name: Checkout (official GitHub action)
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
# Important for versioning plugins:
fetch-depth: 0
- name: Install uv (official Astral action)
- uses: astral-sh/setup-uv@v5
+ uses: astral-sh/setup-uv@v7
with:
# Update this as needed:
- version: "0.6.14"
+ version: "0.9.25"
enable-cache: true
python-version: ${{ matrix.python-version }}
@@ -54,7 +54,10 @@ jobs:
# python-version: ${{ matrix.python-version }}
- name: Install all dependencies
- run: uv sync --all-extras --dev
+ run: uv sync --locked --all-extras --dev
+
+ - name: Report locked tool versions
+ run: uv run python -c "import importlib.metadata as md; print('ruff', md.version('ruff')); print('ssort', md.version('ssort'))"
- name: Run linting
- run: uv run python devtools/lint.py
\ No newline at end of file
+ run: uv run python devtools/lint.py --check
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 4fae866..d0f187c 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -12,14 +12,14 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout (official GitHub action)
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install uv (official Astral action)
- uses: astral-sh/setup-uv@v5
+ uses: astral-sh/setup-uv@v7
with:
- version: "0.6.14"
+ version: "0.9.25"
enable-cache: true
python-version: "3.12"
@@ -28,7 +28,7 @@ jobs:
- name: Install dependencies
# This works now because we are on Windows
- run: uv sync --all-extras --dev
+ run: uv sync --locked --all-extras --dev
- name: Build package
run: uv build
@@ -55,4 +55,4 @@ jobs:
path: dist/
- name: Publish to PyPI
- uses: pypa/gh-action-pypi-publish@release/v1
\ No newline at end of file
+ uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/Makefile b/Makefile
index 50ce7ab..c4eb809 100644
--- a/Makefile
+++ b/Makefile
@@ -9,7 +9,7 @@
default: install lint test
install:
- uv sync --all-extras --dev
+ uv sync --locked --all-extras --dev
lint:
uv run python devtools/lint.py
@@ -18,7 +18,8 @@ test:
uv run pytest --quiet --tb=short
upgrade:
- uv sync --upgrade
+ uv lock --upgrade
+ uv sync --locked --all-extras --dev
build:
uv build
@@ -50,4 +51,4 @@ clean:
$(RM) .pytest_cache/
$(RM) .mypy_cache/
$(RM) .venv/
- $(FIND_PYCACHE)
\ No newline at end of file
+ $(FIND_PYCACHE)
diff --git a/README.md b/README.md
index 95660ac..379426f 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@
| **Address Editing** | One-by-one in app | ✅ **Bulk edit**, multi-window, search/replace |
| **Tag View** | Flat list | ✅ **Color named blocks** + **tree outline** (hierarchy & arrays) |
| **DataView** | Input raw addresses, limited reordering | ✅ **Autocomplete**, add entire grouped structures and blocks, drag and drop reordering |
+| **Ladder Portability** | Copy/paste within app | ✅ **Export** to CSV, **convert** to Python, paste between projects |
| **Price** | Free (bundled) | Free (open source) |
| **Best For** | Simple projects | Complex projects, productivity |
@@ -28,6 +29,7 @@ CLICK PLCs were my first PLC experience, but remembering addresses became painfu
- **[📑 Tag Browser](#tag-browser)** – Tree view with automatic hierarchy and array grouping
- **[📊 Dataview Editor](#dataview-editor)** – Tabbed interface, nickname lookup, unlimited reordering
- **[🔌 Connectivity](#connectivity)** – CSV import and live ODBC database support
+- **[📐 Ladder Tools](#ladder-tools)** – Export ladder logic to CSV, convert to Python, paste between projects
**Beta** – Review Address & Dataview changes before saving in CLICK. [Feedback welcome](https://github.com/ssweber/clicknick/issues).
@@ -143,6 +145,16 @@ Alm[1-2]
---
+### 📐 Ladder Tools
+
+Access these from the **Ladder** menu:
+
+- **Export from Click** – Decode your connected Click project's ladder logic into readable CSV files (powered by [laddercodec](https://github.com/ssweber/laddercodec))
+- **Convert to pyrung** – Generate a [pyrung](https://github.com/ssweber/pyrung) Python project from an exported ladder folder, for unit testing and simulation
+- **Guided Paste** – Load an exported ladder project and walk through importing it into Click, file by file
+
+---
+
Block Tag Specification (Advanced)
diff --git a/devtools/lint.py b/devtools/lint.py
index ec13294..e36e645 100644
--- a/devtools/lint.py
+++ b/devtools/lint.py
@@ -1,51 +1,68 @@
+import argparse
import subprocess
-
-from funlog import log_calls
-from rich import get_console, reconfigure
-from rich import print as rprint
+import sys
# Update as needed.
SRC_PATHS = ["src", "tests", "devtools"]
-DOC_PATHS = ["README.md"]
-reconfigure(emoji=not get_console().options.legacy_windows) # No emojis on legacy windows.
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Run lint checks for the repository.")
+ parser.add_argument(
+ "--check",
+ action="store_true",
+ help="Run read-only checks suitable for CI (no autofix).",
+ )
+ return parser.parse_args()
-@log_calls(level="warning", show_timing_only=True)
def run(cmd: list[str]) -> int:
- rprint()
- rprint(f"[bold green]:arrow_forward: {' '.join(cmd)}[/bold green]")
- errcount = 0
+ print()
+ print(f"==> {' '.join(cmd)}")
try:
subprocess.run(cmd, text=True, check=True)
- except subprocess.CalledProcessError as e:
- rprint(f"[bold red]Error: {e}[/bold red]")
- errcount = 1
+ except subprocess.CalledProcessError as exc:
+ print(f"Error: {exc}")
+ return 1
+ except FileNotFoundError as exc:
+ print(f"Executable not found: {exc}")
+ return 1
- return errcount
+ return 0
-def main():
- rprint()
+def main() -> int:
+ args = parse_args()
+ print()
errcount = 0
- errcount += run(["codespell", "--write-changes", *SRC_PATHS, *DOC_PATHS])
- errcount += run(["ssort", *SRC_PATHS])
- errcount += run(["ruff", "check", "--fix", *SRC_PATHS])
- errcount += run(["ruff", "format", *SRC_PATHS])
+ if args.check:
+ errcount += run(["ssort", "--check", *SRC_PATHS])
+ else:
+ errcount += run(["ssort", *SRC_PATHS])
+
+ if args.check:
+ errcount += run(["ruff", "check", *SRC_PATHS])
+ else:
+ errcount += run(["ruff", "check", "--fix", *SRC_PATHS])
+
+ if args.check:
+ errcount += run(["ruff", "format", "--check", *SRC_PATHS])
+ else:
+ errcount += run(["ruff", "format", *SRC_PATHS])
+
# errcount += run(["basedpyright", *SRC_PATHS])
- rprint()
+ print()
if errcount != 0:
- rprint(f"[bold red]:x: Lint failed with {errcount} errors.[/bold red]")
+ print(f"Lint failed with {errcount} error(s).")
else:
- rprint("[bold green]:white_check_mark: Lint passed![/bold green]")
- rprint()
+ print("Lint passed.")
+ print()
return errcount
if __name__ == "__main__":
- exit(main())
+ sys.exit(main())
diff --git a/extract-click-plc-core-plan.md b/extract-click-plc-core-plan.md
deleted file mode 100644
index aca0be7..0000000
--- a/extract-click-plc-core-plan.md
+++ /dev/null
@@ -1,360 +0,0 @@
-# pyclickplc Extraction Plan
-
-Transition plan for extracting shared CLICK PLC format knowledge from ClickNick into a standalone `pyclickplc` package, consumed by both ClickNick (GUI) and pyrung (simulation engine).
-
----
-
-## Target Package Layout
-
-```
-pyclickplc/
- __init__.py # re-export public API
- banks.py # ADDRESS_RANGES, MEMORY_TYPE_BASES, DataType, defaults
- addresses.py # AddressRecord, get_addr_key, parse/format helpers
- blocks.py # BlockTag, BlockRange, MemoryBankMeta, parse/compute
- nicknames.py # read/write CSV, type code mappings
- validation.py # NICKNAME_MAX_LENGTH, FORBIDDEN_CHARS, validate_nickname()
-```
-
----
-
-## Phase 0: Simplify Block Tags in ClickNick (Before Extraction) - DONE
-
-Do this work in ClickNick first. Extracting clean code is easier than extracting complex code and simplifying it after.
-
-### 0a. Enforce unique block names
-
-- Add validation: on block create/rename, check for duplicate names across all memory types
-- Surface error in editor if duplicate detected
-- Remove stack-per-`(memory_type, name)` logic from `compute_all_block_ranges` — simplify to plain dict lookup by name
-- Remove memory_type scoping from `find_paired_tag_index` — unique names make it unnecessary
-
-### 0b. Add auto-suffix for T/TD, CT/CTD
-
-- Editor behavior: when user creates `` on T rows, auto-create `` on TD rows
-- When user renames `` → ``, auto-rename `` → ``
-- When user deletes ``, auto-delete ``
-- `_D` suffix is convention only — pyclickplc treats them as two independent uniquely-named blocks; pairing is recognized by suffix match
-
-### 0c. Move `compute_all_block_ranges` back to blocktag model
-
-Move from `block_service.py` to `blocktag.py` (now simplified by unique names):
-
-- `find_paired_tag_index` — dict scan by name, no depth tracking
-- `find_block_range_indices` — thin wrapper
-- `validate_block_span` — format constraint
-- `compute_all_block_ranges` — simple open/close matching by unique name
-
-Keep in `BlockService` (ClickNick editor coordination):
-
-- `update_colors` — mutates AddressStore
-- `auto_update_matching_block_tag` — editor auto-sync UX
-- `apply_block_tag` — auto-suffix editor behavior
-- `compute_block_colors_map` — UI rendering helper
-
----
-
-## Phase 1: Extract `banks.py` and `addresses.py`
-
-Lowest risk. Pure data and pure functions with zero behavioral change.
-
-### banks.py — from `constants.py`
-
-Move:
-
-| Source (`constants.py`) | Destination (`banks.py`) |
-|-------------------------------|-------------------------------|
-| `ADDRESS_RANGES` | `ADDRESS_RANGES` |
-| `MEMORY_TYPE_BASES` | `MEMORY_TYPE_BASES` |
-| `_INDEX_TO_TYPE` | `_INDEX_TO_TYPE` |
-| `DataType` enum | `DataType` |
-| `DATA_TYPE_DISPLAY` | `DATA_TYPE_DISPLAY` |
-| `DATA_TYPE_HINTS` | `DATA_TYPE_HINTS` |
-| `MEMORY_TYPE_TO_DATA_TYPE` | `MEMORY_TYPE_TO_DATA_TYPE` |
-| `DEFAULT_RETENTIVE` | `DEFAULT_RETENTIVE` |
-| `INTERLEAVED_PAIRS` | `INTERLEAVED_PAIRS` |
-| `INTERLEAVED_TYPE_PAIRS` | `INTERLEAVED_TYPE_PAIRS` |
-| `BIT_ONLY_TYPES` | `BIT_ONLY_TYPES` |
-
-Keep in ClickNick `constants.py` (GUI-specific):
-
-- `NON_EDITABLE_TYPES`
-- `PAIRED_RETENTIVE_TYPES` (editor behavior for retentive sync)
-
-Update ClickNick: `from pyclickplc.banks import ADDRESS_RANGES, DataType, ...`
-
-### addresses.py — from `address_row.py`
-
-Move:
-
-| Source (`address_row.py`) | Destination (`addresses.py`) |
-|----------------------------------|----------------------------------|
-| `get_addr_key` | `get_addr_key` |
-| `parse_addr_key` | `parse_addr_key` |
-| `format_address_display` | `format_address_display` |
-| `parse_address_display` | `parse_address_display` |
-| `normalize_address` | `normalize_address` |
-| `is_xd_yd_upper_byte` | `is_xd_yd_upper_byte` |
-| `is_xd_yd_hidden_slot` | `is_xd_yd_hidden_slot` |
-| `xd_yd_mdb_to_display` | `xd_yd_mdb_to_display` |
-| `xd_yd_display_to_mdb` | `xd_yd_display_to_mdb` |
-
-New in `addresses.py`:
-
-```python
-@dataclass(frozen=True)
-class AddressRecord:
- """Minimal address representation shared between consumers."""
- memory_type: str
- address: int
- nickname: str = ""
- comment: str = ""
- initial_value: str = ""
- retentive: bool = False
- data_type: int = DataType.BIT
-
- @property
- def addr_key(self) -> int:
- return get_addr_key(self.memory_type, self.address)
-
- @property
- def display_address(self) -> str:
- return format_address_display(self.memory_type, self.address)
-```
-
-Keep in ClickNick `address_row.py`:
-
-- `AddressRow` (adds validation state, GUI display, editor helpers)
-- `AddressRow.from_record(r: AddressRecord)` class method (new, adapter)
-
----
-
-## Phase 2: Extract `blocks.py`
-
-Move block tag model from ClickNick's `blocktag.py` (post Phase 0 simplification).
-
-### blocks.py — from `blocktag.py`
-
-Move:
-
-| Source (`blocktag.py`) | Destination (`blocks.py`) |
-|-------------------------------------|-------------------------------------|
-| `HasComment` protocol | `HasComment` |
-| `BlockTag` dataclass | `BlockTag` |
-| `BlockRange` dataclass | `BlockRange` |
-| `parse_block_tag` | `parse_block_tag` |
-| `format_block_tag` | `format_block_tag` |
-| `extract_block_name` | `extract_block_name` |
-| `strip_block_tag` | `strip_block_tag` |
-| `get_block_type` | `get_block_type` |
-| `is_block_tag` | `is_block_tag` |
-| `_extract_bg_attribute` | `_extract_bg_attribute` |
-| `_is_valid_tag_name` | `_is_valid_tag_name` |
-| `_try_parse_tag_at` | `_try_parse_tag_at` |
-| `find_paired_tag_index` | `find_paired_tag_index` |
-| `find_block_range_indices` | `find_block_range_indices` |
-| `compute_all_block_ranges` | `compute_all_block_ranges` |
-| `validate_block_span` | `validate_block_span` |
-
-New in `blocks.py`:
-
-```python
-@dataclass(frozen=True)
-class MemoryBankMeta:
- """Metadata for a memory bank discovered from block tags."""
- name: str
- memory_type: str
- start_address: int # hardware address
- end_address: int # hardware address, inclusive
- data_type: int
- retentive: bool
- bg_color: str | None = None
- paired_bank: str | None = None # recognized by _D suffix convention
-
-def extract_bank_metas(
- records: list[AddressRecord],
-) -> dict[str, MemoryBankMeta]:
- """Compute MemoryBankMetas from address records using block tags.
-
- Discovers block tag pairs, extracts address ranges and types,
- recognizes _D suffix pairing for timer/counter banks.
-
- Returns:
- Dict mapping block name to MemoryBankMeta
- """
- ...
-```
-
-Keep in ClickNick `block_service.py`:
-
-- `BlockService.update_colors`
-- `BlockService.auto_update_matching_block_tag`
-- `BlockService.apply_block_tag`
-- `BlockService.compute_block_colors_map`
-
-These import parsing functions from `clickplc.blocks` instead of `blocktag.py`.
-
----
-
-## Phase 3: Extract `nicknames.py` and `validation.py`
-
-### nicknames.py — from `data_source.py`
-
-Move:
-
-| Source (`data_source.py`) | Destination (`nicknames.py`) |
-|--------------------------------------|--------------------------------------|
-| `CSV_COLUMNS` | `CSV_COLUMNS` |
-| `DATA_TYPE_STR_TO_CODE` | `DATA_TYPE_STR_TO_CODE` |
-| `DATA_TYPE_CODE_TO_STR` | `DATA_TYPE_CODE_TO_STR` |
-| `ADDRESS_PATTERN` | `ADDRESS_PATTERN` |
-| `load_addresses_from_mdb_dump` | `read_mdb_csv` (rename) |
-| CSV read logic from `CsvDataSource` | `read_csv` |
-| CSV write logic from `CsvDataSource` | `write_csv` |
-| `convert_mdb_csv_to_user_csv` | `convert_mdb_csv_to_user_csv` |
-
-Public API:
-
-```python
-def read_csv(path: str) -> dict[int, AddressRecord]:
- """Read CLICK user-format CSV. Returns addr_key -> AddressRecord."""
- ...
-
-def read_mdb_csv(path: str) -> dict[int, AddressRecord]:
- """Read CLICK Address.csv (MDB export format). Returns addr_key -> AddressRecord."""
- ...
-
-def write_csv(path: str, records: Iterable[AddressRecord]) -> int:
- """Write user-format CSV. Returns number of records written."""
- ...
-
-def load_nickname_file(path: str) -> NicknameProject:
- """High-level loader: reads CSV, extracts block tags, builds bank metas.
-
- Returns:
- NicknameProject with records, banks, and standalone tags
- """
- ...
-
-@dataclass
-class NicknameProject:
- records: dict[int, AddressRecord] # all records
- banks: dict[str, MemoryBankMeta] # discovered from block tags
- tags: dict[str, AddressRecord] # not in any block
-```
-
-Keep in ClickNick `data_source.py`:
-
-- `DataSource` ABC
-- `CsvDataSource` — becomes thin adapter:
-
-```python
-class CsvDataSource(DataSource):
- def load_all_addresses(self) -> dict[int, AddressRow]:
- records = clickplc.read_csv(self._csv_path)
- return {k: AddressRow.from_record(r) for k, r in records.items()}
-
- def save_changes(self, rows: Sequence[AddressRow]) -> int:
- records = [r.to_record() for r in rows if r.has_content]
- return clickplc.write_csv(self._csv_path, records)
-```
-
-- `MdbDataSource` — unchanged, stays entirely in ClickNick
-
-### validation.py — from `constants.py`
-
-Move:
-
-| Source (`constants.py`) | Destination (`validation.py`) |
-|-----------------------------------|-----------------------------------|
-| `NICKNAME_MAX_LENGTH` | `NICKNAME_MAX_LENGTH` |
-| `COMMENT_MAX_LENGTH` | `COMMENT_MAX_LENGTH` |
-| `FORBIDDEN_CHARS` | `FORBIDDEN_CHARS` |
-| `RESERVED_NICKNAMES` | `RESERVED_NICKNAMES` |
-| `INT_MIN/MAX, INT2_MIN/MAX, ...` | Numeric range constants |
-
-New:
-
-```python
-def validate_nickname(name: str) -> tuple[bool, str | None]:
- """Validate a nickname against CLICK rules. Returns (valid, error)."""
- ...
-
-def validate_initial_value(value: str, data_type: int) -> tuple[bool, str | None]:
- """Validate an initial value for the given data type. Returns (valid, error)."""
- ...
-```
-
----
-
-## Phase 4: Update ClickNick Imports
-
-Mechanical find-and-replace across ClickNick:
-
-```python
-# Before
-from ..models.constants import ADDRESS_RANGES, DataType, ...
-from ..models.address_row import get_addr_key, parse_addr_key, ...
-from ..models.blocktag import parse_block_tag, BlockTag, ...
-
-# After
-from pyclickplc.banks import ADDRESS_RANGES, DataType, ...
-from pyclickplc.addresses import get_addr_key, parse_addr_key, AddressRecord
-from pyclickplc.blocks import parse_block_tag, BlockTag, ...
-from pyclickplc.nicknames import read_csv, write_csv
-from pyclickplc.validation import validate_nickname, NICKNAME_MAX_LENGTH, ...
-```
-
-Delete moved code from ClickNick. What remains in each file:
-
-| ClickNick file | Remaining content |
-|-------------------------|------------------------------------------------------|
-| `constants.py` | `NON_EDITABLE_TYPES`, `PAIRED_RETENTIVE_TYPES` |
-| `address_row.py` | `AddressRow` (GUI model), `from_record`, `to_record` |
-| `blocktag.py` | Empty or deleted (re-exports from pyclickplc if needed)|
-| `block_service.py` | `BlockService` (editor coordination only) |
-| `data_source.py` | `DataSource` ABC, `CsvDataSource` (thin), `MdbDataSource` |
-
----
-
-## Phase 5: Wire into pyrung
-
-With pyclickplc published:
-
-```python
-# pyrung/click/__init__.py
-from pyclickplc.banks import ADDRESS_RANGES, DataType, DEFAULT_RETENTIVE
-from pyclickplc.addresses import AddressRecord
-from pyclickplc.blocks import MemoryBankMeta
-from pyclickplc.nicknames import load_nickname_file
-
-# Pre-built banks constructed from pyclickplc data
-X = MemoryBank("X", TagType.BOOL, range(1, ADDRESS_RANGES["X"][1] + 1), retentive=False)
-DS = MemoryBank("DS", TagType.INT, range(1, ADDRESS_RANGES["DS"][1] + 1), retentive=True)
-# etc.
-```
-
-```python
-# pyrung TagMap integration
-class TagMap:
- @classmethod
- def from_nickname_file(cls, path: str) -> "TagMap":
- project = load_nickname_file(path)
- mapping = {}
- for meta in project.banks.values():
- bank = MemoryBank.from_meta(meta)
- mapping[bank] = hardware_slice_from_meta(meta)
- for name, record in project.tags.items():
- tag = tag_from_record(record)
- mapping[tag] = hardware_address_from_record(record)
- return cls(mapping)
-```
-
----
-
-## Future (Not This Plan)
-
-- Mutation API in pyclickplc: `add_block_tag`, `rename_block_tag`, `delete_block_tag`
-- Nickname CSV export from pyrung (round-trip: pyrung → CSV → ClickNick → hardware)
-- `from_nickname_file()` nickname export (generate CSV from TagMap)
-- Click nickname file format changes (if Automation Direct updates)
diff --git a/pyproject.toml b/pyproject.toml
index 823c739..bade393 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -37,7 +37,9 @@ classifiers = [
# ---- Main dependencies ----
dependencies = [
+ "laddercodec",
"pyclickplc",
+ "pyrung",
"pywin32 >= 311",
"pyodbc >= 5.3.0",
"tksheet == 7.5.19",
@@ -49,15 +51,13 @@ dependencies = [
dev = [
"pytest>=8.3.5",
"ruff>=0.11.0",
- "codespell>=2.4.1",
- "rich>=13.9.4",
- "funlog>=0.2.0",
- "ssort>=0.14.0",
+ "ssort==0.16.0",
]
[project.scripts]
# Add script entry points here:
clicknick-dev = "clicknick:main_dev"
+clicknick-rung = "clicknick.ladder.cli:main"
[project.gui-scripts]
clicknick = "clicknick:main"
@@ -86,6 +86,10 @@ bump = true
# The source location for the package.
packages = ["src/clicknick"]
+[tool.uv]
+exclude-newer = "7 days"
+exclude-newer-package = { pyclickplc = "0 days", laddercodec = "0 days", pyrung = "0 days" }
+
# ---- Settings ----
@@ -159,10 +163,8 @@ reportUnreachable = false
# reportUnknownVariableType = false
# reportUnknownArgumentType = false
-[tool.codespell]
-# Add here as needed:
-# ignore-words-list = "foo,bar"
-# skip = "foo.py,bar.py"
+[tool.ssort]
+exclude = ["src/clicknick/ladder/**", "src/clicknick/csv/**"]
[tool.pytest.ini_options]
python_files = ["*.py"]
diff --git a/scratchpad/dataview-live-plan.md b/scratchpad/dataview-live-plan.md
deleted file mode 100644
index 1a303be..0000000
--- a/scratchpad/dataview-live-plan.md
+++ /dev/null
@@ -1,165 +0,0 @@
-# Plan: ClickNick Dataview Live Integration Requirements
-
-## Scope
-
-This plan now covers only ClickNick-side requirements.
-
-`ModbusService` implementation and service-level batching behavior are planned in:
-
-- `../pyclickplc/scratchpad/modbus-service-plan.md`
-
-ClickNick should treat that service as an external dependency and only integrate against its public API.
-
-## Required pyclickplc API
-
-ClickNick integration assumes `pyclickplc` provides:
-
-- `ConnectionState`
-- `ModbusService`
-- `set_poll_addresses(addresses: Iterable[str])`
-- `clear_poll_addresses()`
-- `write(values: Mapping[str, PlcValue] | Iterable[tuple[str, PlcValue]])`
-- service callbacks for state and values
-
-## ClickNick Requirements
-
-### 1. DataviewPanel UI + data flow
-
-File: `src/clicknick/views/dataview_editor/panel.py`
-
-- Show 6 columns:
- - `Address`
- - `Nickname`
- - `Comment`
- - `New Value` (editable for writable rows only)
- - `Write` (checkbox)
- - `Live` (read-only)
-- Use explicit `DataViewFile` helpers for New Value and Live formatting/parsing:
- - `DataViewFile.value_to_display(...)` for UI display text
- - `DataViewFile.validate_row_display(...)` for edit validation
- - `DataViewFile.try_parse_display(...)` or `DataViewFile.set_row_new_value_from_display(...)` for native value updates
-- Keep write checkboxes only on writable, non-empty-address rows.
-- Keep `New Value` effectively read-only for non-writable addresses.
-
-Panel methods required by window integration:
-
-- `get_poll_addresses() -> list[str]`:
- - must return only valid, non-empty, canonical-uppercase addresses
- - must deduplicate while preserving row order
-- `update_live_values(values: Mapping[str, PlcValue]) -> None`
-- `clear_live_values() -> None`
-- `get_write_rows() -> list[tuple[str, PlcValue]]` (checked + writable only)
-- `get_write_all_rows() -> list[tuple[str, PlcValue]]` (all writable rows with non-empty new value)
-- `clear_write_checks() -> None`
-
-### 2. DataviewEditorWindow integration
-
-File: `src/clicknick/views/dataview_editor/window.py`
-
-- Add a dedicated `Modbus` toolbar section in the main Dataview window (primary connection UX):
- - `Host` input
- - `Port` input
- - `Connect/Disconnect` toggle button
- - connection state indicator text
- - optional last-error text
-- Add `Connection` menu:
- - `Connect`
- - `Disconnect`
-- Do not add separate connect/disconnect windows; connection controls live in the toolbar.
-- Keep `Connection` menu as secondary entry points that call the same toolbar-backed handlers and use current toolbar host/port values.
-- Own a service instance:
- - `self._modbus: ModbusService | None`
-- On connect:
- - wire `on_state` and `on_values` callbacks
- - marshal callback UI updates with `self.after(0, ...)`
- - immediately sync poll addresses from the currently visible tab after successful connect
- - if there is no visible tab, call `self._modbus.clear_poll_addresses()`
-- On tab change:
- - clear `Live` values in the tab being hidden
- - replace poll list with newly active tab addresses
- - if there is no active tab, call `self._modbus.clear_poll_addresses()`
-- On tab close:
- - if the closed tab was active, recompute from new active tab and replace poll list
- - if no tabs remain, call `self._modbus.clear_poll_addresses()`
-- On address edits:
- - if edit happened in active tab, replace poll list with active tab addresses
- - if edit happened in inactive tab, do not change poll list
-- Add toolbar actions:
- - `Write Checked`: send `panel.get_write_rows()`
- - `Write All`: send `panel.get_write_all_rows()`
- - both disabled unless connected
- - grouped with Modbus connect controls in the same toolbar region
-- On successful write completion:
- - call `panel.clear_write_checks()`
-- On disconnect:
- - call `self._modbus.clear_poll_addresses()` before disconnect (or equivalent stop behavior)
- - clear live values in all open panels
-- On window close:
- - call `self._modbus.disconnect()` before destroy
-
-### 3. Write behavior requirements
-
-- `Write Checked` writes only checked writable rows.
-- `Write All` writes all writable rows with non-empty `new_value`.
-- Non-writable rows must never be included in ClickNick write payloads.
-- ClickNick payload should be address + native value only; service owns protocol/write strategy.
-
-### 4. Poll behavior requirements
-
-- Poll list is replace-based per active context, not additive subscription.
-- Only the currently visible tab is polled.
-- Hidden tabs are never polled.
-- Active tab fully controls poll list (single source of truth).
-- Connect immediately syncs poll list from active tab (no user action required).
-- Address edits in active tab refresh poll list; edits in inactive tabs do not.
-- Switching away from a tab clears that tab's `Live` values.
-- No active tab (for example, last tab closed) must clear poll addresses in service.
-- Disconnect clears live display values in all open tabs.
-
-## ClickNick Test Requirements
-
-1. Panel behavior
-- New Value edit validation and normalization uses `DataViewFile` helpers.
-- Write checkbox lifecycle tracks writability/address presence.
-- `get_write_rows()` and `get_write_all_rows()` include only writable rows.
-- `get_poll_addresses()` returns canonical uppercase, valid-only, de-duplicated addresses.
-
-2. Window integration
-- Mocks `pyclickplc.ModbusService`.
-- Verifies Modbus toolbar controls drive connect/disconnect handlers.
-- Verifies `Connection` menu entries call the same handlers as toolbar controls.
-- Verifies connect immediately pushes active tab poll list.
-- Verifies tab change replaces poll list and clears hidden tab live values.
-- Verifies active-tab address edits call `set_poll_addresses(...)`.
-- Verifies inactive-tab address edits do not call `set_poll_addresses(...)`.
-- Verifies closing active/last tab clears poll addresses.
-- Verifies no-tab state calls `clear_poll_addresses()`.
-- Verifies state/value callbacks are marshaled via `after(...)`.
-- Verifies write actions call service with expected payloads.
-- Verifies connect/disconnect button/menu enablement and cleanup.
-
-Suggested file:
-
-- `tests/test_dataview_modbus_integration.py`
-
-## Files (ClickNick only)
-
-- `src/clicknick/views/dataview_editor/panel.py`
-- `src/clicknick/views/dataview_editor/window.py`
-- `tests/test_dataview_modbus_integration.py`
-
-## Verification (ClickNick)
-
-1. `make lint`
-2. `make test`
-3. Manual:
- - Open `.cdv` -> 6 columns visible
- - Modbus toolbar visible with host/port + connect controls
- - Edit `New Value` -> valid normalization + invalid rejection
- - Connect from toolbar -> active tab starts live polling immediately
- - Tab change -> previous tab live values clear; new active tab values populate
- - Active-tab address change -> poll list updates immediately
- - Close last tab -> polling stops
- - Write Checked -> only checked writable rows written
- - Write All -> only writable rows with values written
- - Disconnect/close -> live values cleared and service disconnected
diff --git a/src/clicknick/app.py b/src/clicknick/app.py
index ff99304..1732ec0 100644
--- a/src/clicknick/app.py
+++ b/src/clicknick/app.py
@@ -1,6 +1,7 @@
import tkinter as tk
from ctypes import windll
-from tkinter import PhotoImage, filedialog, font, ttk
+from pathlib import Path
+from tkinter import PhotoImage, filedialog, font, messagebox, ttk
from .config import AppSettings
from .data.address_store import AddressStore
@@ -487,6 +488,286 @@ def _clean_mdb(self):
except Exception as e:
self._update_status(f"Error cleaning MDB: {e}", "error")
+ @staticmethod
+ def _get_export_popup_flag() -> Path:
+ """Get path to the flag indicating the Export beta popup has been seen."""
+ import os
+
+ base = Path(os.environ.get("LOCALAPPDATA", Path.home()))
+ return base / "ClickNick" / "export_from_click_popup_seen"
+
+ def _show_export_popup(self) -> None:
+ """Show first-run info for Export from Click (appears once per user)."""
+ flag_path = self._get_export_popup_flag()
+
+ if flag_path.exists():
+ return
+
+ popup_text = (
+ "Export from Click\n\n"
+ "This feature decodes CLICK's internal program files into CSV.\n"
+ "Contacts, instructions, or entire rungs may decode incorrectly\n"
+ "or be missing. Email, Home, Position, and Velocity instructions\n"
+ "are exported as raw(...) placeholders.\n\n"
+ "If you encounter errors or unexpected output, please report\n"
+ "them — sample programs help us improve the decoder."
+ )
+
+ messagebox.showinfo("First-Time Tips", popup_text, parent=self.root)
+
+ flag_path.parent.mkdir(parents=True, exist_ok=True)
+ flag_path.touch()
+
+ def _export_from_click(self):
+ """Export Scr*.tmp from the connected Click project to a CSV bundle."""
+ if not self.connected_click_hwnd:
+ messagebox.showwarning(
+ "Export from Click",
+ "Not connected to a Click project.\n\nStart monitoring first.",
+ parent=self.root,
+ )
+ return
+
+ from pathlib import Path
+
+ from .utils.mdb_shared import find_click_database
+
+ db_path = find_click_database(click_hwnd=self.connected_click_hwnd)
+ if not db_path:
+ messagebox.showerror(
+ "Export from Click",
+ "Could not locate the Click project folder.",
+ parent=self.root,
+ )
+ return
+ scr_folder = Path(db_path).parent
+
+ self._show_export_popup()
+
+ output = filedialog.askdirectory(
+ title="Export from Click — choose output folder",
+ parent=self.root,
+ )
+ if not output:
+ return
+
+ from .ladder.program import program_save
+
+ while True:
+ try:
+ result = program_save(scr_folder, Path(output))
+ except (FileNotFoundError, ValueError) as exc:
+ messagebox.showerror("Export from Click", str(exc), parent=self.root)
+ return
+ except PermissionError as exc:
+ retry = messagebox.askretrycancel(
+ "Export from Click",
+ f"Cannot write to output folder — a file may be open"
+ f" in another program.\n\n{exc}",
+ parent=self.root,
+ )
+ if retry:
+ continue
+ return
+ break
+
+ # Write nicknames.csv from the MDB alongside the ladder CSVs
+ nick_count = 0
+ try:
+ from .data.data_source import CsvDataSource
+ from .utils.mdb_operations import MdbConnection, load_all_addresses
+
+ with MdbConnection(str(db_path)) as conn:
+ all_rows = load_all_addresses(conn)
+ nick_dest = Path(output) / "nicknames.csv"
+ nick_count = CsvDataSource(str(nick_dest)).save_changes(list(all_rows.values()))
+ except PermissionError:
+ messagebox.showwarning(
+ "Export from Click",
+ "Could not write nicknames.csv — file may be open.\n"
+ "Ladder CSVs were exported successfully.",
+ parent=self.root,
+ )
+ except Exception:
+ pass # nicknames export is best-effort
+
+ sub_count = len(result.subroutine_csvs)
+ parts = [f"{result.total_rungs} rungs", f"{sub_count} subroutine(s)"]
+ if nick_count:
+ parts.append(f"{nick_count} tags")
+ self._update_status(
+ f"Exported {', '.join(parts)} to {output}",
+ "connected",
+ )
+
+ def _convert_to_pyrung(self):
+ """Convert a ladder CSV folder into a pyrung Python project."""
+ source = filedialog.askdirectory(
+ title="Convert to pyrung — choose ladder CSV folder",
+ parent=self.root,
+ )
+ if not source:
+ return
+
+ from pathlib import Path
+
+ source_path = Path(source)
+ if not (source_path / "main.csv").exists():
+ messagebox.showerror(
+ "Convert to pyrung",
+ f"No main.csv found in {source}.\n\n"
+ "Use 'Export from Click' first to create a ladder folder.",
+ parent=self.root,
+ )
+ return
+
+ output = filedialog.askdirectory(
+ title="Convert to pyrung — choose output folder",
+ parent=self.root,
+ )
+ if not output:
+ return
+
+ from pyrung.click import ladder_to_pyrung_project
+
+ nickname_csv = source_path / "nicknames.csv"
+ try:
+ files = ladder_to_pyrung_project(
+ source_path,
+ nickname_csv=nickname_csv if nickname_csv.exists() else None,
+ output_dir=Path(output),
+ )
+ except Exception as exc:
+ messagebox.showerror("Convert to pyrung", str(exc), parent=self.root)
+ return
+
+ self._update_status(
+ f"Exported {len(files)} file(s) to {output}",
+ "connected",
+ )
+
+ def _load_ladder_csv(self):
+ """Load a single ladder CSV file to the Click clipboard."""
+ csv_file = filedialog.askopenfilename(
+ title="Load Ladder CSV to Clipboard",
+ filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
+ parent=self.root,
+ )
+ if not csv_file:
+ return
+
+ from pathlib import Path
+
+ from .ladder.clipboard import copy_to_clipboard
+ from .ladder.program import prepare_csv_load
+ from .utils.mdb_shared import find_click_database
+
+ csv_path = Path(csv_file)
+
+ mdb_path = None
+ if self.connected_click_hwnd:
+ db_path = find_click_database(click_hwnd=self.connected_click_hwnd)
+ if db_path:
+ mdb_path = Path(db_path)
+
+ try:
+ result = prepare_csv_load(csv_path, mdb_path=mdb_path)
+ except (ValueError, RuntimeError) as exc:
+ messagebox.showerror(
+ "Load Ladder CSV",
+ str(exc),
+ parent=self.root,
+ )
+ return
+
+ try:
+ copy_to_clipboard(result.payload, owner_hwnd=self.connected_click_hwnd)
+ except RuntimeError as exc:
+ messagebox.showerror(
+ "Load Ladder CSV",
+ f"Could not copy to clipboard:\n\n{exc}",
+ parent=self.root,
+ )
+ return
+
+ rungs = f"{result.rung_count} rung{'s' if result.rung_count != 1 else ''}"
+ self._update_status(
+ f"Copied {rungs} from {csv_path.name} to clipboard",
+ "connected",
+ )
+
+ def _save_clipboard_csv(self):
+ """Save Click clipboard data to a CSV file."""
+ csv_file = filedialog.asksaveasfilename(
+ title="Save Clipboard to CSV",
+ defaultextension=".csv",
+ filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
+ parent=self.root,
+ )
+ if not csv_file:
+ return
+
+ from pathlib import Path
+
+ from .ladder.clipboard import read_from_clipboard
+ from .ladder.program import decode_to_csv
+
+ try:
+ data = read_from_clipboard()
+ except RuntimeError as exc:
+ messagebox.showerror(
+ "Save Clipboard to CSV",
+ str(exc),
+ parent=self.root,
+ )
+ return
+
+ csv_path = Path(csv_file)
+ try:
+ decode_to_csv(data, csv_path)
+ except Exception as exc:
+ messagebox.showerror(
+ "Save Clipboard to CSV",
+ f"Could not decode clipboard data:\n\n{exc}",
+ parent=self.root,
+ )
+ return
+
+ self._update_status(
+ f"Saved clipboard to {csv_path.name}",
+ "connected",
+ )
+
+ def _open_guided_paste(self):
+ """Open a folder of ladder CSVs in the guided paste panel."""
+ folder = filedialog.askdirectory(
+ title="Open in Guided Paste — choose ladder CSV folder",
+ parent=self.root,
+ )
+ if not folder:
+ return
+
+ from pathlib import Path
+
+ from .utils.mdb_shared import find_click_database
+ from .views.guided_paste_window import GuidedPasteWindow
+
+ folder_path = Path(folder)
+
+ def get_mdb_path() -> Path | None:
+ """Resolve MDB from the currently connected Click instance."""
+ if not self.connected_click_hwnd:
+ return None
+ db_path = find_click_database(click_hwnd=self.connected_click_hwnd)
+ return Path(db_path) if db_path else None
+
+ GuidedPasteWindow(
+ self.root,
+ folder_path,
+ get_mdb_path=get_mdb_path,
+ get_click_hwnd=lambda: self.connected_click_hwnd,
+ )
+
def _create_menu_bar(self):
"""Create the application menu bar."""
menubar = tk.Menu(self.root)
@@ -495,9 +776,9 @@ def _create_menu_bar(self):
# File menu
file_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="File", menu=file_menu)
- file_menu.add_command(label="Exit", command=self.on_closing)
- file_menu.add_separator()
file_menu.add_command(label="Load Nicknames from CSV...", command=self.browse_and_load_csv)
+ file_menu.add_separator()
+ file_menu.add_command(label="Exit", command=self.on_closing)
# Tools menu
tools_menu = tk.Menu(menubar, tearoff=0)
@@ -509,6 +790,18 @@ def _create_menu_bar(self):
tools_menu.add_command(label="Verify MDB & CDV...", command=self._verify_mdb_and_cdv)
tools_menu.add_command(label="Clean MDB...", command=self._clean_mdb)
+ # Ladder menu
+ ladder_menu = tk.Menu(menubar, tearoff=0)
+ menubar.add_cascade(label="Ladder (beta)", menu=ladder_menu)
+ ladder_menu.add_command(
+ label="Load Ladder CSV to Clipboard...", command=self._load_ladder_csv
+ )
+ ladder_menu.add_command(label="Open in Guided Paste...", command=self._open_guided_paste)
+ ladder_menu.add_separator()
+ ladder_menu.add_command(label="Save Clipboard to CSV...", command=self._save_clipboard_csv)
+ ladder_menu.add_command(label="Export from Click...", command=self._export_from_click)
+ ladder_menu.add_command(label="Convert to pyrung...", command=self._convert_to_pyrung)
+
# Help menu
help_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="Help", menu=help_menu)
diff --git a/src/clicknick/ladder/CLAUDE.md b/src/clicknick/ladder/CLAUDE.md
new file mode 100644
index 0000000..1bc9329
--- /dev/null
+++ b/src/clicknick/ladder/CLAUDE.md
@@ -0,0 +1,54 @@
+# Ladder Module — CLAUDE Workflow
+
+## Scope
+
+The encoder/decoder core lives in the standalone [`laddercodec`](https://github.com/ssweber/laddercodec) package. This directory (`ladder/`) contains **clipboard I/O, program service functions, and the CLI**:
+
+- **program.py** — Service functions (encode, decode, MDB provisioning, program save/load). Pure logic, no print/input/sys.exit. Used by both CLI and GUI.
+- **cli.py** — `clicknick-rung` CLI. Thin wrapper: argparse, interactive prompts, presentation. Calls program.py services.
+- **clipboard.py** — Win32 clipboard interaction (copy/read/clear, Click format 522)
+
+For encoder/codec work, **work in the laddercodec repo** (`../laddercodec`).
+
+## Dependency
+
+`laddercodec` is an editable local dep. Both repos must be sibling directories:
+
+```toml
+[tool.uv.sources]
+laddercodec = { path = "../laddercodec", editable = true }
+```
+
+## CLI usage
+
+```bash
+clicknick-rung guided FOLDER # Interactive batch verify CSVs
+clicknick-rung guided FOLDER --list # List CSVs with descriptions
+clicknick-rung guided FOLDER --restart # Clear progress, start fresh
+clicknick-rung program load FOLDER # Load CSV bundle into Click (guided paste)
+clicknick-rung program save FOLDER # Decode Scr*.tmp → main.csv + subroutines/
+clicknick-rung load FILE # Encode .csv/.bin → clipboard
+clicknick-rung save FILE # Clipboard → .bin/.csv/both
+clicknick-rung --mdb-path SC_.mdb ... # Explicit MDB path (any subcommand)
+```
+
+### Interactive prompt (guided)
+
+For each CSV: shows description, CSV shape, copies to clipboard, then prompts:
+
+- **[w]orked** — paste worked; copy rung back for comparison/saving
+- **[c]rashed** — Click crashed; optionally record details in progress log
+- **[n]ot as expected** — pasted but looks wrong; record description in progress log, optionally save `.bin`
+- **[s]kip** — skip this fixture
+- **[q]uit** — stop
+
+Progress is tracked in `verify_progress.log` in the folder. Re-running resumes automatically; `--restart` clears it.
+
+### MDB integration
+
+Auto-detects the running Click project's SC_.mdb and ensures operand addresses exist before pasting (required for contacts/coils).
+
+## Shell hygiene
+
+Never inline multi-line content in bash commands (echo, printf, python -c, cat < Path:
+ """Find SC_.mdb from explicit path or auto-detect from running Click."""
+ if mdb_path:
+ resolved = Path(mdb_path)
+ if not resolved.exists():
+ raise FileNotFoundError(f"MDB path not found: {resolved}")
+ return resolved
+
+ click_hwnd = find_click_hwnd()
+ db_path = find_click_database(None, click_hwnd)
+ if not db_path:
+ raise FileNotFoundError("Could not locate SC_.mdb. Pass --mdb-path .")
+ resolved = Path(db_path)
+ if not resolved.exists():
+ raise FileNotFoundError(f"Auto-detected SC_.mdb not found: {resolved}")
+ return resolved
+
+
+# ---------------------------------------------------------------------------
+# CSV description (CLI-only presentation)
+# ---------------------------------------------------------------------------
+
+
+def print_csv_shape(csv_path: Path) -> None:
+ """Print the CSV data rows (skip header) as-is."""
+ import csv as csv_mod
+
+ with open(csv_path, newline="", encoding="utf-8") as f:
+ reader = csv_mod.reader(f)
+ next(reader) # skip header
+ for row in reader:
+ print(f" {','.join(row)}")
+
+
+# ---------------------------------------------------------------------------
+# Loaders (encode → provision MDB → clipboard, with CLI output)
+# ---------------------------------------------------------------------------
+
+
+def _load_csv(csv_path: Path, mdb_path: str | None, *, best_effort: bool = False) -> bytes:
+ """Describe, encode, provision MDB addresses, copy to clipboard. Return payload."""
+ print(f" {describe_csv(csv_path, best_effort=best_effort)}")
+ print_csv_shape(csv_path)
+
+ try:
+ resolved_mdb = resolve_mdb_path(mdb_path)
+ except (FileNotFoundError, RuntimeError):
+ resolved_mdb = None
+
+ result = prepare_csv_load(csv_path, mdb_path=resolved_mdb, best_effort=best_effort)
+
+ if result.addresses_inserted:
+ print(
+ f" MDB: inserted {result.addresses_inserted} address(es) into {result.mdb_path.name}"
+ )
+ elif result.mdb_error:
+ print(f" MDB: skipped ({result.mdb_error})")
+
+ copy_to_clipboard(result.payload)
+ print(f" Copied to clipboard ({len(result.payload):,} bytes)")
+ return result.payload
+
+
+def _load_program_csv(csv_path: Path, mdb_path: str | None) -> bytes:
+ """Encode a program/subroutine CSV and copy to clipboard. Minimal output."""
+ try:
+ resolved_mdb = resolve_mdb_path(mdb_path)
+ except (FileNotFoundError, RuntimeError):
+ resolved_mdb = None
+
+ result = prepare_csv_load(csv_path, mdb_path=resolved_mdb)
+
+ print(f" {result.rung_count} rung{'s' if result.rung_count != 1 else ''}")
+ if result.addresses_inserted:
+ print(
+ f" MDB: inserted {result.addresses_inserted} address(es) into {result.mdb_path.name}"
+ )
+ elif result.mdb_error:
+ print(f" MDB: skipped ({result.mdb_error})")
+
+ copy_to_clipboard(result.payload)
+ print(f" Copied to clipboard ({len(result.payload):,} bytes)")
+ return result.payload
+
+
+# ---------------------------------------------------------------------------
+# Clipboard save + compare (verification)
+# ---------------------------------------------------------------------------
+
+
+def _save_and_compare(name: str, bin_path: Path) -> None:
+ """Read clipboard, save as .bin, and report byte diff if .bin already exists."""
+ actual = read_from_clipboard()
+
+ if not bin_path.exists():
+ bin_path.write_bytes(actual)
+ print(f" Saved {bin_path.name} ({len(actual):,} bytes)")
+ return
+
+ expected = bin_path.read_bytes()
+ if actual == expected:
+ print(f" {name}: exact match ({len(actual):,} bytes)")
+ elif len(actual) == len(expected):
+ diffs = [i for i in range(len(actual)) if actual[i] != expected[i]]
+ print(f" {name}: {len(diffs)} byte(s) differ from encoder, first at 0x{diffs[0]:04X}")
+ else:
+ print(f" {name}: size differs (encoder {len(expected):,}, Click {len(actual):,} bytes)")
+
+
+# ---------------------------------------------------------------------------
+# Progress log
+# ---------------------------------------------------------------------------
+
+
+def _read_progress(log_path: Path) -> dict[str, str]:
+ """Read progress log, return {name: status_line}."""
+ done: dict[str, str] = {}
+ if not log_path.exists():
+ return done
+ for line in log_path.read_text(encoding="utf-8").splitlines():
+ line = line.strip()
+ if not line or line.startswith("#"):
+ continue
+ if ": " in line:
+ name = line.split(": ", 1)[0]
+ done[name] = line
+ return done
+
+
+def _append_result(log_path: Path, name: str, status: str, detail: str = "") -> None:
+ """Append one result line to the progress log."""
+ extra = f" ({detail})" if detail else ""
+ line = f"{name}: {status}{extra}\n"
+ with open(log_path, "a+", encoding="utf-8") as f:
+ f.seek(0, 2) # seek to end
+ if f.tell() > 0:
+ f.seek(f.tell() - 1)
+ if f.read(1) != "\n":
+ f.write("\n")
+ f.write(line)
+
+
+# ---------------------------------------------------------------------------
+# Interactive batch verification
+# ---------------------------------------------------------------------------
+
+_Loader = Any # Callable[[Path, str | None], bytes]
+
+
+def _run_guided(
+ items: list[tuple[str, Path]],
+ *,
+ mdb_path: str | None = None,
+ log_path: Path,
+ loader: _Loader = None,
+ save_copyback_bin: bool = True,
+) -> None:
+ """Interactive batch verify. Each item is (name, csv_path).
+
+ Progress is appended one line at a time to *log_path*. On resume,
+ already-completed fixtures are skipped automatically.
+ """
+ if loader is None:
+ loader = _load_csv
+ done = _read_progress(log_path)
+ if done:
+ print(f"Resuming: {len(done)} already done, {len(items) - len(done)} remaining")
+ else:
+ with open(log_path, "w", encoding="utf-8") as f:
+ f.write(f"# Verify {datetime.now(tz=UTC).isoformat()}\n")
+
+ print()
+ print("For each fixture: encodes and copies to clipboard.")
+ print("After pasting in Click, enter one of:")
+ if save_copyback_bin:
+ print(" [w]orked - paste worked, copy the rung back for comparison/saving")
+ else:
+ print(" [w]orked - paste worked")
+ print(" [c]rashed - Click crashed or errored")
+ print(" [n]ot as expected - pasted but looks wrong")
+ print(" [s]kip - skip this fixture")
+ print(" [q]uit - stop")
+ print()
+
+ results: list[tuple[str, str, str]] = []
+
+ for idx, (name, csv_path) in enumerate(items):
+ if name in done:
+ continue
+
+ print(f"--- {name} ({idx + 1}/{len(items)}) ---")
+
+ try:
+ loader(csv_path, mdb_path)
+ except RuntimeError as exc:
+ loaded = False
+ while not loaded:
+ print(f" Clipboard error: {exc}")
+ print(" Open Click and press Enter to retry, or [s]kip / [q]uit")
+ choice = input(" > ").strip().lower()
+ if choice == "q":
+ print()
+ return
+ if choice == "s":
+ results.append((name, "skipped", str(exc)))
+ _append_result(log_path, name, "skipped", str(exc))
+ break
+ try:
+ loader(csv_path, mdb_path)
+ loaded = True
+ except RuntimeError as exc2:
+ exc = exc2
+ if not loaded:
+ print()
+ continue
+ except Exception as exc:
+ print(f" Error: {exc}")
+ results.append((name, "error", str(exc)))
+ _append_result(log_path, name, "error", str(exc))
+ print()
+ continue
+
+ print(" Paste in Click, then: [w]orked / [c]rashed / [n]ot as expected / [s]kip / [q]uit")
+ response = input(" > ").strip().lower()
+
+ if response == "q":
+ print()
+ break
+
+ if response == "s":
+ results.append((name, "skipped", ""))
+ _append_result(log_path, name, "skipped")
+ print()
+ continue
+
+ if response == "n":
+ note = input(" What looked wrong? > ").strip()
+ if save_copyback_bin:
+ print(" Copy the rung back if you want to save it, or just press Enter to skip.")
+ save = input(" Save .bin? [y/N] > ").strip().lower()
+ if save == "y":
+ try:
+ data = read_from_clipboard()
+ except RuntimeError as exc:
+ print(f" Clipboard error: {exc}")
+ else:
+ bin_path = csv_path.with_suffix(".bin")
+ bin_path.write_bytes(data)
+ print(f" Saved {bin_path.name} ({len(data):,} bytes)")
+ results.append((name, "unexpected", note or "no description"))
+ _append_result(log_path, name, "unexpected", note or "no description")
+ print()
+ continue
+
+ if response == "c":
+ note = input(" Any details? (Enter to skip) > ").strip()
+ results.append((name, "crashed", note or ""))
+ _append_result(log_path, name, "crashed", note or "")
+ print()
+ continue
+
+ # Default: "w" or Enter — worked OK.
+ if save_copyback_bin:
+ bin_path = csv_path.with_suffix(".bin")
+ print(" Copy the rung back from Click, then press Enter.")
+ input(" > ")
+ while True:
+ try:
+ _save_and_compare(name, bin_path)
+ break
+ except RuntimeError as exc:
+ print(f" Clipboard error: {exc}")
+ print(" Copy the rung back and press Enter to retry, or [s]kip")
+ if input(" > ").strip().lower() == "s":
+ break
+ results.append((name, "worked", ""))
+ _append_result(log_path, name, "worked")
+ print()
+
+ # Summary
+ all_results: list[tuple[str, str, str]] = []
+ for name, _ in items:
+ if name in done:
+ rest = done[name].split(": ", 1)[1]
+ if " (" in rest and rest.endswith(")"):
+ status, detail = rest.split(" (", 1)
+ detail = detail[:-1]
+ else:
+ status, detail = rest, ""
+ all_results.append((name, status, detail))
+ else:
+ for rname, rstatus, rdetail in results:
+ if rname == name:
+ all_results.append((name, rstatus, rdetail))
+ break
+
+ print()
+ print("=" * 50)
+ print("Results:")
+ for name, status, detail in all_results:
+ if detail:
+ print(f" {name}: {status} — {detail}")
+ else:
+ print(f" {name}: {status}")
+
+ counts: dict[str, int] = {}
+ for _, status, _ in all_results:
+ counts[status] = counts.get(status, 0) + 1
+ remaining = len(items) - len(all_results)
+ summary = ", ".join(f"{v} {k}" for k, v in sorted(counts.items()))
+ if remaining:
+ summary += f", {remaining} remaining"
+ print(f"\nTotal: {summary}")
+ print(f"Progress log: {log_path}")
+
+ has_failures = any(s in ("crashed", "unexpected", "error") for _, s, _ in all_results)
+ sys.exit(1 if has_failures else 0)
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(
+ prog="clicknick-rung",
+ description="Clipboard bridge for CLICK PLC ladder rungs.",
+ )
+ parser.add_argument("--mdb-path", metavar="PATH", help="Explicit path to SC_.mdb")
+ subparsers = parser.add_subparsers(dest="command")
+
+ # --- guided ---
+ guided = subparsers.add_parser(
+ "guided",
+ help="Interactive batch verify CSVs in a folder",
+ )
+ guided.add_argument("folder", metavar="FOLDER", help="Directory containing CSV fixtures")
+ guided.add_argument("--list", action="store_true", help="List fixtures with descriptions")
+ guided.add_argument("--restart", action="store_true", help="Clear progress and start fresh")
+
+ # --- program (with load/save subcommands) ---
+ program = subparsers.add_parser(
+ "program",
+ help="Program bundle operations (load into Click or save from Scr*.tmp)",
+ )
+ prog_sub = program.add_subparsers(dest="program_command")
+
+ prog_load = prog_sub.add_parser(
+ "load",
+ help="Load a CSV bundle into Click via guided clipboard paste",
+ )
+ prog_load.add_argument(
+ "folder", metavar="FOLDER", help="Directory with main.csv and subroutines/"
+ )
+ prog_load.add_argument("--restart", action="store_true", help="Clear progress and start fresh")
+
+ prog_save = prog_sub.add_parser(
+ "save",
+ help="Decode Scr*.tmp files into a CSV bundle (main.csv + subroutines/)",
+ )
+ prog_save.add_argument("folder", metavar="FOLDER", help="Directory containing Scr*.tmp files")
+ prog_save.add_argument(
+ "--output", "-o", metavar="DIR", help="Output directory for CSV bundle (default: FOLDER)"
+ )
+
+ # --- load ---
+ load = subparsers.add_parser(
+ "load",
+ help="Encode a .csv or .bin file and copy to clipboard",
+ )
+ load.add_argument("file", metavar="FILE", help="Path to .csv or .bin file")
+ load.add_argument(
+ "--best-effort",
+ action="store_true",
+ help="Skip unsupported AF instructions instead of failing",
+ )
+
+ # --- save ---
+ save = subparsers.add_parser(
+ "save",
+ help="Save clipboard to file (.bin, .csv, or both)",
+ )
+ save.add_argument(
+ "file",
+ metavar="FILE",
+ help="Output path: .bin for binary, .csv for decoded CSV, no extension for both",
+ )
+
+ args = parser.parse_args()
+
+ if not args.command:
+ parser.print_help()
+ sys.exit(1)
+
+ if args.command == "guided":
+ folder = Path(args.folder)
+ if not folder.is_dir():
+ print(f"Error: not a directory: {folder}", file=sys.stderr)
+ sys.exit(1)
+ csvs = sorted(folder.glob("*.csv"))
+ if not csvs:
+ print(f"No CSV files found in {folder}")
+ sys.exit(1)
+
+ if args.list:
+ for csv_path in csvs:
+ name = csv_path.stem
+ try:
+ desc = describe_csv(csv_path)
+ except ValueError as exc:
+ desc = f""
+ bin_path = csv_path.with_suffix(".bin")
+ if bin_path.exists():
+ size = bin_path.stat().st_size
+ tag = f" [verified, {size:,} bytes]"
+ else:
+ tag = ""
+ print(f" {name}{tag}")
+ print(f" {desc}")
+ return
+
+ items = [(p.stem, p) for p in csvs]
+ log = folder / "verify_progress.log"
+ if args.restart and log.exists():
+ log.unlink()
+ print(f"Folder: {folder} ({len(items)} CSV files)")
+ _run_guided(items, mdb_path=args.mdb_path, log_path=log)
+ return
+
+ if args.command == "program":
+ if not args.program_command:
+ program.print_help()
+ sys.exit(1)
+
+ folder = Path(args.folder)
+ if not folder.is_dir():
+ print(f"Error: not a directory: {folder}", file=sys.stderr)
+ sys.exit(1)
+
+ if args.program_command == "save":
+ output = Path(args.output) if args.output else None
+ try:
+ result = program_save(folder, output)
+ except (FileNotFoundError, ValueError) as exc:
+ print(f"Error: {exc}", file=sys.stderr)
+ sys.exit(1)
+
+ for info in result.programs:
+ print(
+ f" {info.source} → {info.name!r}"
+ f" (idx={info.prog_idx}, {info.rung_count} rungs)"
+ )
+ print(f" Saved {result.main_csv}")
+ for csv_path in result.subroutine_csvs:
+ print(f" Saved {csv_path}")
+ print(
+ f"\nProgram bundle: 1 main + {len(result.subroutine_csvs)} subroutine(s),"
+ f" {result.total_rungs} total rungs"
+ )
+ return
+
+ # program load
+ try:
+ items = list_program_bundle(folder)
+ except FileNotFoundError as exc:
+ print(f"Error: {exc}", file=sys.stderr)
+ sys.exit(1)
+
+ print(f"Program bundle: {folder}")
+ for name, _ in items:
+ print(f" {name}")
+ print()
+
+ log = folder / "verify_progress.log"
+ if args.restart and log.exists():
+ log.unlink()
+ _run_guided(
+ items,
+ mdb_path=args.mdb_path,
+ log_path=log,
+ loader=_load_program_csv,
+ save_copyback_bin=False,
+ )
+ return
+
+ if args.command == "load":
+ file_path = Path(args.file)
+ if not file_path.exists():
+ print(f"Error: file not found: {file_path}", file=sys.stderr)
+ sys.exit(1)
+ try:
+ if file_path.suffix.lower() == ".csv":
+ _load_csv(file_path, args.mdb_path, best_effort=args.best_effort)
+ else:
+ data = file_path.read_bytes()
+ copy_to_clipboard(data)
+ print(f"Copied {file_path.name} to clipboard ({len(data):,} bytes)")
+ except (RuntimeError, ValueError) as exc:
+ print(f"Error: {exc}", file=sys.stderr)
+ sys.exit(1)
+ return
+
+ if args.command == "save":
+ file_path = Path(args.file)
+ try:
+ data = read_from_clipboard()
+ except RuntimeError as exc:
+ print(f"Error: {exc}", file=sys.stderr)
+ sys.exit(1)
+
+ suffix = file_path.suffix.lower()
+
+ if suffix == ".bin":
+ file_path.write_bytes(data)
+ print(f"Saved {file_path} ({len(data):,} bytes)")
+ elif suffix == ".csv":
+ try:
+ decode_to_csv(data, file_path)
+ except (WriterError, Exception) as exc:
+ print(f"Error: could not decode to CSV: {exc}", file=sys.stderr)
+ sys.exit(1)
+ print(f"Saved {file_path}")
+ else:
+ bin_path = file_path.with_suffix(".bin")
+ csv_path = file_path.with_suffix(".csv")
+ bin_path.write_bytes(data)
+ print(f"Saved {bin_path} ({len(data):,} bytes)")
+ try:
+ decode_to_csv(data, csv_path)
+ print(f"Saved {csv_path}")
+ except (WriterError, Exception) as exc:
+ print(
+ f"Warning: could not write CSV: {exc}",
+ file=sys.stderr,
+ )
+ return
diff --git a/src/clicknick/ladder/clipboard.py b/src/clicknick/ladder/clipboard.py
new file mode 100644
index 0000000..c44db91
--- /dev/null
+++ b/src/clicknick/ladder/clipboard.py
@@ -0,0 +1,132 @@
+"""Clipboard I/O for Click PLC Programming Software.
+
+Uses Win32 API to read/write Click's private clipboard format (format 522).
+Requires pywin32.
+"""
+
+from __future__ import annotations
+
+import ctypes
+import ctypes.wintypes
+import time
+
+CLICK_CLIPBOARD_FORMAT = 522 # 0x020A
+_CLIPBOARD_OPEN_RETRIES = 20
+_CLIPBOARD_OPEN_DELAY_S = 0.05
+
+_user32 = ctypes.windll.user32
+_kernel32 = ctypes.windll.kernel32
+_kernel32.GlobalAlloc.restype = ctypes.c_void_p
+_kernel32.GlobalAlloc.argtypes = [ctypes.c_uint, ctypes.c_size_t]
+_kernel32.GlobalLock.restype = ctypes.c_void_p
+_kernel32.GlobalLock.argtypes = [ctypes.c_void_p]
+_kernel32.GlobalUnlock.argtypes = [ctypes.c_void_p]
+_kernel32.GlobalFree.argtypes = [ctypes.c_void_p]
+_user32.SetClipboardData.restype = ctypes.c_void_p
+_user32.SetClipboardData.argtypes = [ctypes.c_uint, ctypes.c_void_p]
+_user32.OpenClipboard.argtypes = [ctypes.c_void_p]
+_user32.GetClipboardOwner.restype = ctypes.c_void_p
+_user32.EmptyClipboard.restype = ctypes.c_bool
+_user32.CloseClipboard.restype = ctypes.c_bool
+GMEM_MOVEABLE = 0x0002
+
+
+def find_click_hwnds() -> list[int]:
+ """Find all visible Click Programming Software main window handles."""
+ import win32gui
+
+ results: list[int] = []
+
+ def callback(hwnd, _):
+ if "CLICK Programming Software" in win32gui.GetWindowText(hwnd):
+ results.append(hwnd)
+ return True
+
+ win32gui.EnumWindows(callback, None)
+ # Deterministic ordering simplifies click: selection at call-sites.
+ return sorted(results)
+
+
+def find_click_hwnd() -> int:
+ """Find Click Programming Software's main window handle."""
+
+ results = find_click_hwnds()
+ if not results:
+ raise RuntimeError("Click Programming Software not found. Is it running?")
+ return results[0]
+
+
+def _open_clipboard_with_retry(owner_hwnd: int | None = None) -> None:
+ owner = owner_hwnd or 0
+ for _ in range(_CLIPBOARD_OPEN_RETRIES):
+ if _user32.OpenClipboard(owner):
+ return
+ time.sleep(_CLIPBOARD_OPEN_DELAY_S)
+ if owner_hwnd:
+ raise RuntimeError(
+ f"OpenClipboard failed for HWND 0x{owner_hwnd:08X} "
+ f"after {_CLIPBOARD_OPEN_RETRIES} attempts"
+ )
+ raise RuntimeError(f"OpenClipboard failed after {_CLIPBOARD_OPEN_RETRIES} attempts")
+
+
+def clear_clipboard(owner_hwnd: int | None = None) -> None:
+ """Clear all clipboard formats with retry for transient clipboard locks."""
+ _open_clipboard_with_retry(owner_hwnd)
+ try:
+ if not _user32.EmptyClipboard():
+ raise RuntimeError("EmptyClipboard failed")
+ finally:
+ _user32.CloseClipboard()
+
+
+def copy_to_clipboard(data: bytes, owner_hwnd: int | None = None) -> None:
+ """Place bytes on clipboard in Click's private format.
+
+ Args:
+ data: Clipboard payload bytes.
+ owner_hwnd:
+ - None: auto-spoof first CLICK window HWND (current behavior).
+ - 0: no owner spoof (OpenClipboard(0)).
+ - non-zero: explicit owner HWND.
+ """
+ hwnd = find_click_hwnd() if owner_hwnd is None else owner_hwnd
+ _open_clipboard_with_retry(hwnd)
+ try:
+ if not _user32.EmptyClipboard():
+ raise RuntimeError("EmptyClipboard failed")
+
+ hmem = _kernel32.GlobalAlloc(GMEM_MOVEABLE, len(data))
+ if not hmem:
+ raise RuntimeError("GlobalAlloc failed")
+
+ ptr = _kernel32.GlobalLock(hmem)
+ if not ptr:
+ _kernel32.GlobalFree(hmem)
+ raise RuntimeError("GlobalLock failed")
+
+ ctypes.memmove(ptr, data, len(data))
+ _kernel32.GlobalUnlock(hmem)
+
+ if not _user32.SetClipboardData(CLICK_CLIPBOARD_FORMAT, hmem):
+ _kernel32.GlobalFree(hmem)
+ raise RuntimeError("SetClipboardData failed")
+ finally:
+ _user32.CloseClipboard()
+
+
+def read_from_clipboard() -> bytes:
+ """Read Click clipboard data.
+
+ Raises RuntimeError if Click's clipboard format is not present.
+ """
+ import win32clipboard
+
+ win32clipboard.OpenClipboard()
+ try:
+ raw = win32clipboard.GetClipboardData(CLICK_CLIPBOARD_FORMAT)
+ return bytes(raw) if not isinstance(raw, bytes) else raw
+ except TypeError:
+ raise RuntimeError("No Click rung data on clipboard.") from None
+ finally:
+ win32clipboard.CloseClipboard()
diff --git a/src/clicknick/ladder/program.py b/src/clicknick/ladder/program.py
new file mode 100644
index 0000000..62fe498
--- /dev/null
+++ b/src/clicknick/ladder/program.py
@@ -0,0 +1,505 @@
+"""Service functions for ladder program operations.
+
+Pure logic — no print(), no input(), no sys.exit().
+Returns structured data; callers decide presentation.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from pathlib import Path
+
+from laddercodec import (
+ Coil,
+ CompareContact,
+ Contact,
+ Timer,
+ decode,
+ decode_program,
+ encode,
+ read_csv,
+ write_csv,
+)
+from laddercodec.csv import CONDITION_COLUMNS
+from laddercodec.encode import AfToken, ConditionToken
+from pyclickplc.addresses import format_address_display, get_addr_key, parse_address
+
+from ..utils.mdb_operations import ensure_addresses_exist
+
+# ---------------------------------------------------------------------------
+# Constants
+# ---------------------------------------------------------------------------
+
+ADDRESS_TOKEN_RE = re.compile(r"(?:"/\\|?*]')
+
+
+# ---------------------------------------------------------------------------
+# Result types
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class ProgramInfo:
+ """Metadata about a decoded Scr*.tmp program."""
+
+ source: str
+ name: str
+ prog_idx: int
+ rung_count: int
+
+
+@dataclass
+class SaveResult:
+ """Result of program_save(): decoded Scr*.tmp files into a CSV bundle."""
+
+ main_csv: Path
+ subroutine_csvs: list[Path]
+ programs: list[ProgramInfo]
+ total_rungs: int
+
+
+@dataclass
+class PrepareResult:
+ """Result of prepare_csv_load(): encoded payload + MDB provisioning info."""
+
+ payload: bytes
+ rung_count: int
+ addresses: list[str]
+ addresses_inserted: int
+ mdb_path: Path | None
+ mdb_error: str | None
+
+
+# ---------------------------------------------------------------------------
+# Address extraction
+# ---------------------------------------------------------------------------
+
+
+def _extract_operand_candidates(token: ConditionToken | AfToken) -> list[str]:
+ def _append(value: str | None, *, address_only: bool = False) -> None:
+ if not value:
+ return
+ candidate = value.strip().upper()
+ if not candidate:
+ return
+ if address_only and not ADDRESS_TOKEN_RE.fullmatch(candidate):
+ return
+ out.append(candidate)
+
+ out: list[str] = []
+
+ if isinstance(token, Contact):
+ _append(token.operand)
+ return out
+ if isinstance(token, Coil):
+ _append(token.operand)
+ _append(token.range_end)
+ return out
+ if isinstance(token, CompareContact):
+ _append(token.left)
+ _append(token.right)
+ return out
+ if isinstance(token, Timer):
+ _append(token.done_bit)
+ _append(token.current)
+ _append(token.setpoint, address_only=True)
+ return out
+
+ if isinstance(token, str):
+ text = token.strip().upper()
+ elif hasattr(token, "to_csv"):
+ text = token.to_csv().upper()
+ else:
+ return []
+ if not text:
+ return []
+ for match in ADDRESS_RANGE_RE.finditer(text):
+ _append(match.group(1))
+ _append(match.group(2))
+ for match in ADDRESS_TOKEN_RE.finditer(text):
+ _append(match.group(1))
+ return list(dict.fromkeys(out))
+
+
+def extract_addresses_from_csv(path: Path, *, best_effort: bool = False) -> list[str]:
+ """Parse operand addresses from a CSV file (single or multi-rung)."""
+ rungs = read_csv(path, strict=not best_effort)
+ seen_keys: set[int] = set()
+ parsed: list[str] = []
+ for rung in rungs:
+ for conditions, af in zip(rung.conditions, rung.instructions, strict=True):
+ for token in [*conditions, af]:
+ for candidate in _extract_operand_candidates(token):
+ try:
+ memory_type, address = parse_address(candidate)
+ except ValueError:
+ continue
+ addr_key = get_addr_key(memory_type, address)
+ if addr_key in seen_keys:
+ continue
+ seen_keys.add(addr_key)
+ parsed.append(format_address_display(memory_type, address))
+ return parsed
+
+
+# ---------------------------------------------------------------------------
+# CSV description helpers
+# ---------------------------------------------------------------------------
+
+
+def _collapse_cols(cols: list[str], all_col_names: tuple[str, ...]) -> str:
+ """Collapse column lists into ranges: A+C..AE or just A+C."""
+ if not cols:
+ return ""
+ name_to_idx = {name: i for i, name in enumerate(all_col_names)}
+ indices = [name_to_idx[c] for c in cols]
+
+ runs: list[list[int]] = []
+ for idx in indices:
+ if runs and idx == runs[-1][-1] + 1:
+ runs[-1].append(idx)
+ else:
+ runs.append([idx])
+
+ parts: list[str] = []
+ for run in runs:
+ if len(run) >= 3:
+ parts.append(f"{all_col_names[run[0]]}..{all_col_names[run[-1]]}")
+ else:
+ parts.extend(all_col_names[i] for i in run)
+ return "+".join(parts)
+
+
+def _describe_row_wires(conditions: list[str], col_names: tuple[str, ...]) -> str:
+ """Describe wire tokens on a single row, e.g. '-:A+C..AE T:B' or 'full'."""
+ by_token: dict[str, list[str]] = {}
+ for i, tok in enumerate(conditions):
+ if tok in ("-", "|", "T"):
+ by_token.setdefault(tok, []).append(col_names[i])
+
+ if not by_token:
+ return ""
+
+ dash_cols = by_token.get("-", [])
+ if len(dash_cols) == len(col_names):
+ return "full"
+
+ parts: list[str] = []
+ for tok in ("-", "T", "|"):
+ cols = by_token.get(tok, [])
+ if not cols:
+ continue
+ col_str = _collapse_cols(cols, col_names)
+ parts.append(f"{tok}:{col_str}")
+ return " ".join(parts)
+
+
+def _describe_single_rung(
+ condition_rows: list[list[str]],
+ af_tokens: list[str],
+ comment: str | None,
+ col_names: tuple[str, ...],
+) -> str:
+ """Return a human-readable summary of a single rung's shape."""
+ logical_rows = len(condition_rows)
+ parts: list[str] = []
+ parts.append(f"{logical_rows} row{'s' if logical_rows > 1 else ''}")
+
+ if comment is not None:
+ if len(comment) > 40:
+ parts.append(f"comment ({len(comment)} chars)")
+ else:
+ parts.append(f'comment "{comment}"')
+
+ raw_descs: list[tuple[int, str]] = []
+ for i, conditions in enumerate(condition_rows):
+ wire_desc = _describe_row_wires(conditions, col_names)
+ af = af_tokens[i] if i < len(af_tokens) else ""
+ if af and wire_desc:
+ raw_descs.append((i, f"{wire_desc} AF={af}"))
+ elif af:
+ raw_descs.append((i, f"AF={af}"))
+ elif wire_desc:
+ raw_descs.append((i, wire_desc))
+
+ row_descs: list[str] = []
+ j = 0
+ while j < len(raw_descs):
+ start_idx, desc = raw_descs[j]
+ end_idx = start_idx
+ while (
+ j + 1 < len(raw_descs)
+ and raw_descs[j + 1][1] == desc
+ and raw_descs[j + 1][0] == end_idx + 1
+ ):
+ j += 1
+ end_idx = raw_descs[j][0]
+ if end_idx > start_idx:
+ row_descs.append(f"[{start_idx}..{end_idx}] {desc}")
+ else:
+ row_descs.append(f"[{start_idx}] {desc}")
+ j += 1
+ if row_descs:
+ parts.append("; ".join(row_descs))
+
+ return ", ".join(parts)
+
+
+def describe_csv(csv_path: Path, *, best_effort: bool = False) -> str:
+ """Return a human-readable summary of a CSV fixture's shape."""
+ rungs = read_csv(csv_path, strict=not best_effort)
+ if len(rungs) > 1:
+ rung_descs = [
+ _describe_single_rung(r.conditions, r.instructions, r.comment, CONDITION_COLUMNS)
+ for r in rungs
+ ]
+ return f"{len(rungs)} rungs: " + " | ".join(rung_descs)
+
+ r = rungs[0]
+ return _describe_single_rung(r.conditions, r.instructions, r.comment, CONDITION_COLUMNS)
+
+
+# ---------------------------------------------------------------------------
+# Encoding / decoding
+# ---------------------------------------------------------------------------
+
+
+def encode_csv(csv_path: Path, *, best_effort: bool = False) -> bytes:
+ """Encode a CSV file and return the payload bytes."""
+ rungs = read_csv(csv_path, strict=not best_effort)
+ if len(rungs) > 1:
+ return encode(rungs)
+ return encode(rungs[0])
+
+
+def decode_to_csv(data: bytes, path: Path) -> None:
+ """Decode clipboard/program bytes and write canonical CSV."""
+ result = decode(data)
+ rungs = result if isinstance(result, list) else [result]
+ write_csv(path, rungs)
+
+
+# ---------------------------------------------------------------------------
+# Filename utilities
+# ---------------------------------------------------------------------------
+
+
+def _slugify(name: str) -> str:
+ """Convert a program name to a filesystem-safe stem while preserving case."""
+ stem = _WINDOWS_UNSAFE_FILENAME_RE.sub("", name.strip())
+ stem = re.sub(r" {2,}", " ", stem)
+ stem = stem.rstrip(" .")
+ return stem or "Untitled"
+
+
+def _dedupe_filename_stem(stem: str, used_stems: set[str]) -> str:
+ """Return a unique filename stem using a Windows-style ``(N)`` suffix."""
+ candidate = stem
+ next_suffix = 2
+ while candidate.casefold() in used_stems:
+ candidate = f"{stem} ({next_suffix})"
+ next_suffix += 1
+ used_stems.add(candidate.casefold())
+ return candidate
+
+
+# ---------------------------------------------------------------------------
+# Core operations
+# ---------------------------------------------------------------------------
+
+
+def program_save(scr_folder: Path, output: Path | None = None) -> SaveResult:
+ """Decode all Scr*.tmp files into a CSV bundle.
+
+ Writes main.csv for prog_idx 1 and subroutines/{name}.csv for prog_idx 2+.
+ Output defaults to *scr_folder* unless *output* is given.
+
+ Raises FileNotFoundError if no Scr*.tmp files found.
+ Raises ValueError if no main program (prog_idx=1) found.
+ """
+ scr_files = sorted(scr_folder.glob("Scr*.tmp"))
+ if not scr_files:
+ raise FileNotFoundError(f"No Scr*.tmp files found in {scr_folder}")
+
+ dest = output or scr_folder
+ dest.mkdir(parents=True, exist_ok=True)
+
+ from laddercodec.model import Program
+
+ raw_programs: list[Program] = []
+ infos: list[ProgramInfo] = []
+ for scr_path in scr_files:
+ data = scr_path.read_bytes()
+ prog = decode_program(data)
+ raw_programs.append(prog)
+ infos.append(
+ ProgramInfo(
+ source=scr_path.name,
+ name=prog.name,
+ prog_idx=prog.prog_idx,
+ rung_count=len(prog.rungs),
+ )
+ )
+
+ raw_programs.sort(key=lambda p: p.prog_idx)
+ infos.sort(key=lambda i: i.prog_idx)
+
+ main_progs = [p for p in raw_programs if p.prog_idx == 1]
+ sub_progs = [p for p in raw_programs if p.prog_idx > 1]
+
+ if not main_progs:
+ raise ValueError("No main program (prog_idx=1) found")
+
+ main_csv = dest / "main.csv"
+ write_csv(main_csv, main_progs[0].rungs)
+
+ subroutine_csvs: list[Path] = []
+ if sub_progs:
+ sub_dir = dest / "subroutines"
+ sub_dir.mkdir(exist_ok=True)
+ used_stems: set[str] = set()
+ for prog in sub_progs:
+ stem = _dedupe_filename_stem(_slugify(prog.name), used_stems)
+ csv_path = sub_dir / f"{stem}.csv"
+ write_csv(csv_path, prog.rungs)
+ subroutine_csvs.append(csv_path)
+
+ total_rungs = sum(len(p.rungs) for p in raw_programs)
+
+ return SaveResult(
+ main_csv=main_csv,
+ subroutine_csvs=subroutine_csvs,
+ programs=infos,
+ total_rungs=total_rungs,
+ )
+
+
+def prepare_csv_load(
+ csv_path: Path,
+ *,
+ mdb_path: Path | None = None,
+ best_effort: bool = False,
+ show_nicknames: bool = False,
+) -> PrepareResult:
+ """Encode a CSV file and provision MDB addresses.
+
+ Does NOT copy to clipboard — the caller handles that so it can choose
+ the owner HWND (GUI passes its tracked Click window, CLI auto-detects).
+ """
+ rungs = read_csv(csv_path, strict=not best_effort)
+ payload = (
+ encode(rungs, show_nicknames=show_nicknames)
+ if len(rungs) > 1
+ else encode(rungs[0], show_nicknames=show_nicknames)
+ )
+
+ addresses = extract_addresses_from_csv(csv_path, best_effort=best_effort)
+ addresses_inserted = 0
+ mdb_error = None
+
+ if addresses and mdb_path:
+ try:
+ result = ensure_addresses_exist(str(mdb_path), addresses)
+ addresses_inserted = result.get("inserted_count", 0)
+ except (FileNotFoundError, RuntimeError) as exc:
+ mdb_error = str(exc)
+
+ return PrepareResult(
+ payload=payload,
+ rung_count=len(rungs),
+ addresses=addresses,
+ addresses_inserted=addresses_inserted,
+ mdb_path=mdb_path if not mdb_error else None,
+ mdb_error=mdb_error,
+ )
+
+
+@dataclass(frozen=True)
+class NicknameImportResult:
+ """Result of importing nicknames.csv into an MDB database."""
+
+ rows_written: int
+ error: str | None = None
+
+
+def import_nicknames_csv(csv_path: Path, mdb_path: Path) -> NicknameImportResult:
+ """Import nicknames from CSV into MDB database.
+
+ Reads a nicknames.csv (CsvDataSource format) and upserts rows into the
+ Access database so that addresses referenced by ladder CSVs already have
+ their nicknames and comments populated.
+ """
+ from ..data.data_source import CsvDataSource
+ from ..utils.mdb_operations import MdbConnection, save_changes
+
+ try:
+ rows = CsvDataSource(str(csv_path)).load_all_addresses()
+ if not rows:
+ return NicknameImportResult(rows_written=0)
+
+ with MdbConnection(str(mdb_path)) as conn:
+ n = save_changes(conn, list(rows.values()))
+ return NicknameImportResult(rows_written=n)
+ except Exception as exc:
+ return NicknameImportResult(rows_written=0, error=str(exc))
+
+
+def list_csv_folder(folder: Path) -> list[tuple[str, Path]]:
+ """List CSV files in a folder for guided paste.
+
+ Excludes ``nicknames.csv``. Main-level CSVs come first (sorted),
+ then ``subroutines/*.csv`` (sorted).
+ """
+ items: list[tuple[str, Path]] = []
+ for p in sorted(folder.glob("*.csv")):
+ if p.name.lower() == "nicknames.csv":
+ continue
+ items.append((p.name, p))
+ sub_dir = folder / "subroutines"
+ if sub_dir.is_dir():
+ for p in sorted(sub_dir.glob("*.csv")):
+ items.append((f"subroutines/{p.name}", p))
+ return items
+
+
+def read_csv_comment(csv_path: Path) -> str:
+ """Return the first ``# …`` comment line from a CSV, or ``''``."""
+ try:
+ with open(csv_path, encoding="utf-8") as f:
+ first_line = f.readline().strip()
+ if first_line.startswith("#"):
+ return first_line.lstrip("#").strip()
+ except OSError:
+ pass
+ return ""
+
+
+def count_csv_rungs(csv_path: Path) -> int:
+ """Count rungs in a ladder CSV. Returns 0 on parse error."""
+ try:
+ return len(read_csv(csv_path, strict=False))
+ except Exception:
+ return 0
+
+
+def list_program_bundle(folder: Path) -> list[tuple[str, Path]]:
+ """List CSV items in a program bundle (subroutines first, then main).
+
+ Raises FileNotFoundError if main.csv is missing.
+ """
+ main_csv = folder / "main.csv"
+ if not main_csv.exists():
+ raise FileNotFoundError(f"{folder} is missing required main.csv")
+
+ items: list[tuple[str, Path]] = []
+ sub_dir = folder / "subroutines"
+ if sub_dir.is_dir():
+ for p in sorted(sub_dir.glob("*.csv")):
+ items.append((p.stem, p))
+ items.append(("Main Program", main_csv))
+ return items
diff --git a/src/clicknick/services/block_service.py b/src/clicknick/services/block_service.py
index 47c7b72..7b1d5e2 100644
--- a/src/clicknick/services/block_service.py
+++ b/src/clicknick/services/block_service.py
@@ -6,6 +6,7 @@
from __future__ import annotations
+import re
from dataclasses import replace
from typing import TYPE_CHECKING
@@ -44,6 +45,11 @@
]
+_DOTTED_NAME_WITH_FLAG_RE = re.compile(
+ r"^(?P[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+)(?P\s.+)$"
+)
+
+
def _transform_block_name_for_pair(name: str, source_type: str, target_type: str) -> str:
"""Transform block name for interleaved pair sync.
@@ -59,6 +65,23 @@ def _transform_block_name_for_pair(name: str, source_type: str, target_type: str
Returns:
Transformed block name for the target type
"""
+ # Structured block metadata keeps everything after ':' intact.
+ # Example: "Timer:block" -> "Timer_D:block"
+ split_at = name.find(":")
+ if split_at != -1:
+ base_name = name[:split_at]
+ suffix = name[split_at:]
+ else:
+ base_name = name
+ suffix = ""
+
+ # Dotted names may carry trailing flags/metadata after a space.
+ # Example: "Custom.TasksStatus READONLY" -> "Custom.TasksStatus_D READONLY"
+ dotted_match = _DOTTED_NAME_WITH_FLAG_RE.fullmatch(name)
+ if dotted_match is not None:
+ base_name = dotted_match.group("base")
+ suffix = dotted_match.group("suffix")
+
# Determine if source and target are base or data types
base_types = {"T", "CT"}
data_types = {"TD", "CTD"}
@@ -70,15 +93,15 @@ def _transform_block_name_for_pair(name: str, source_type: str, target_type: str
if source_is_base and target_is_data:
# T -> TD or CT -> CTD: add _D suffix if not already present
- if not name.endswith("_D"):
- return name + "_D"
- return name
+ if not base_name.endswith("_D"):
+ return base_name + "_D" + suffix
+ return base_name + suffix
if source_is_data and target_is_base:
# TD -> T or CTD -> CT: remove _D suffix if present
- if name.endswith("_D"):
- return name[:-2]
- return name
+ if base_name.endswith("_D"):
+ return base_name[:-2] + suffix
+ return base_name + suffix
# Same type category (shouldn't happen in normal use)
return name
diff --git a/src/clicknick/utils/mdb_operations.py b/src/clicknick/utils/mdb_operations.py
index 4598da6..89628af 100644
--- a/src/clicknick/utils/mdb_operations.py
+++ b/src/clicknick/utils/mdb_operations.py
@@ -5,9 +5,11 @@
from __future__ import annotations
+from pathlib import Path
from typing import TYPE_CHECKING
import pyodbc
+from pyclickplc.addresses import format_address_display, get_addr_key, parse_address
from pyclickplc.banks import DEFAULT_RETENTIVE, MEMORY_TYPE_TO_DATA_TYPE, DataType
from ..models.address_row import AddressRow
@@ -15,6 +17,7 @@
if TYPE_CHECKING:
from collections.abc import Sequence
+ from typing import Any
class MdbConnection:
@@ -272,3 +275,79 @@ def save_changes(conn: MdbConnection, rows: Sequence[AddressRow]) -> int:
raise
finally:
cursor.close()
+
+
+def ensure_addresses_exist(db_path: str, addresses: Sequence[str]) -> dict[str, Any]:
+ """Ensure all requested addresses exist in the MDB address table.
+
+ Args:
+ db_path: Full path to SC_.mdb
+ addresses: Address strings such as "X001", "CT1", or endpoint ranges "X001..X005"
+
+ Returns:
+ Summary with deterministic counts and normalized address strings.
+ """
+ db_path_obj = Path(db_path)
+ if not db_path_obj.exists():
+ raise FileNotFoundError(f"MDB file not found: {db_path_obj}")
+
+ requested_keys: set[int] = set()
+ requested_pairs: list[tuple[str, int]] = []
+
+ def _add_candidate(candidate: str) -> None:
+ memory_type, address = parse_address(candidate.strip().upper())
+ addr_key = get_addr_key(memory_type, address)
+ if addr_key in requested_keys:
+ return
+ requested_keys.add(addr_key)
+ requested_pairs.append((memory_type, address))
+
+ for raw in addresses:
+ token = raw.strip().upper()
+ if not token:
+ continue
+ if ".." in token:
+ left_raw, right_raw = token.split("..", 1)
+ _add_candidate(left_raw)
+ _add_candidate(right_raw)
+ continue
+ _add_candidate(token)
+
+ parsed_addresses = [format_address_display(mem, addr) for mem, addr in requested_pairs]
+ requested_count = len(parsed_addresses)
+
+ with MdbConnection(str(db_path_obj)) as conn:
+ existing = load_all_addresses(conn)
+ existing_keys = set(existing.keys())
+
+ missing_rows: list[AddressRow] = []
+ for memory_type, address in requested_pairs:
+ addr_key = get_addr_key(memory_type, address)
+ if addr_key in existing_keys:
+ continue
+
+ missing_rows.append(
+ AddressRow(
+ memory_type=memory_type,
+ address=address,
+ nickname="",
+ comment="",
+ used=True,
+ data_type=MEMORY_TYPE_TO_DATA_TYPE.get(memory_type, DataType.BIT),
+ initial_value="",
+ retentive=DEFAULT_RETENTIVE.get(memory_type, False),
+ )
+ )
+
+ if missing_rows:
+ save_changes(conn, missing_rows)
+
+ inserted_count = len(missing_rows)
+ existing_count = requested_count - inserted_count
+ return {
+ "db_path": str(db_path_obj),
+ "requested_count": requested_count,
+ "inserted_count": inserted_count,
+ "existing_count": existing_count,
+ "parsed_addresses": parsed_addresses,
+ }
diff --git a/src/clicknick/views/dataview_editor/window.py b/src/clicknick/views/dataview_editor/window.py
index f87ab28..76d4cb8 100644
--- a/src/clicknick/views/dataview_editor/window.py
+++ b/src/clicknick/views/dataview_editor/window.py
@@ -13,7 +13,7 @@
from tkinter import filedialog, messagebox, ttk
from typing import TYPE_CHECKING
-from pyclickplc import ConnectionState, ModbusService
+from pyclickplc import ConnectionState, ModbusService, ReconnectConfig
from pyclickplc.addresses import get_addr_key
from pyclickplc.banks import INTERLEAVED_PAIRS
@@ -204,6 +204,7 @@ def _ensure_modbus_service(self) -> ModbusService:
self._modbus = ModbusService(
on_state=self._on_modbus_state_callback,
on_values=self._on_modbus_values_callback,
+ reconnect=ReconnectConfig(delay_s=0.5, max_delay_s=5.0),
)
return self._modbus
@@ -287,7 +288,7 @@ def _connect_modbus(self) -> None:
self._update_modbus_controls()
self._run_modbus_action(
- lambda: service.connect(host, port),
+ lambda: service.connect(host, port, timeout=3),
lambda _result, error: self._on_connect_modbus_complete(error),
)
diff --git a/src/clicknick/views/guided_paste_window.py b/src/clicknick/views/guided_paste_window.py
new file mode 100644
index 0000000..c4fc96f
--- /dev/null
+++ b/src/clicknick/views/guided_paste_window.py
@@ -0,0 +1,402 @@
+"""Guided paste panel for loading a folder of ladder CSVs into Click."""
+
+from __future__ import annotations
+
+import tkinter as tk
+from collections.abc import Callable
+from pathlib import Path
+from tkinter import messagebox, ttk
+
+from tksheet import Sheet
+
+from ..ladder.clipboard import copy_to_clipboard
+from ..ladder.program import (
+ count_csv_rungs,
+ import_nicknames_csv,
+ list_csv_folder,
+ prepare_csv_load,
+ read_csv_comment,
+)
+
+# Column layout
+COL_STATUS = 0
+COL_FILE = 1
+COL_RUNGS = 2
+COL_DESC = 3
+_NUM_COLS = 4
+
+# Icons
+_ICON_DONE = "\u2713" # ✓
+_ICON_CURRENT = "\u25b6" # ▶
+_ICON_PENDING = "\u25cb" # ○
+
+# Row background colours
+_BG_DONE = "#d4edda"
+_BG_CURRENT = "#cce5ff"
+
+# Special display name for the nicknames import step
+_NICKNAMES_NAME = "nicknames.csv"
+
+
+class GuidedPasteWindow(tk.Toplevel):
+ """Non-modal panel that walks the user through pasting ladder CSVs."""
+
+ # ------------------------------------------------------------------
+ # Folder scanning
+ # ------------------------------------------------------------------
+
+ def _scan_folder(self) -> None:
+ # Insert nicknames.csv as the first step if it exists
+ nicks = self._folder / "nicknames.csv"
+ if nicks.exists():
+ self._nickname_path = nicks
+ self._items.append((_NICKNAMES_NAME, nicks, 0, "Import nicknames into MDB"))
+
+ raw = list_csv_folder(self._folder)
+ for name, path in raw:
+ rungs = count_csv_rungs(path)
+ desc = read_csv_comment(path)
+ self._items.append((name, path, rungs, desc))
+
+ def _is_nickname_step(self, idx: int) -> bool:
+ return idx is not None and idx < len(self._items) and self._items[idx][0] == _NICKNAMES_NAME
+
+ def _update_progress_text(self) -> None:
+ done_n = sum(1 for name, *_ in self._items if name in self._done)
+ self._progress_lbl.configure(text=f"{done_n} of {len(self._items)} done")
+
+ # ------------------------------------------------------------------
+ # Row highlighting
+ # ------------------------------------------------------------------
+
+ def _highlight_row(self, idx: int, bg: str) -> None:
+ for col in range(_NUM_COLS):
+ self._sheet.highlight_cells(row=idx, column=col, bg=bg)
+
+ def _dehighlight_row(self, idx: int) -> None:
+ for col in range(_NUM_COLS):
+ self._sheet.dehighlight_cells(row=idx, column=col)
+
+ def _update_highlights(self) -> None:
+ for idx, (name, *_) in enumerate(self._items):
+ if name in self._done:
+ self._highlight_row(idx, _BG_DONE)
+ self._sheet.set_cell_data(idx, COL_STATUS, _ICON_DONE)
+ elif idx == self._current_idx:
+ self._highlight_row(idx, _BG_CURRENT)
+ self._sheet.set_cell_data(idx, COL_STATUS, _ICON_CURRENT)
+ else:
+ self._dehighlight_row(idx)
+ self._sheet.set_cell_data(idx, COL_STATUS, _ICON_PENDING)
+ self._sheet.set_refresh_timer()
+ self._update_progress_text()
+
+ # ------------------------------------------------------------------
+ # Sheet data
+ # ------------------------------------------------------------------
+
+ def _populate_sheet(self) -> None:
+ data = []
+ for name, _path, rungs, desc in self._items:
+ icon = _ICON_DONE if name in self._done else _ICON_PENDING
+ rung_str = "" if name == _NICKNAMES_NAME else str(rungs)
+ data.append([icon, name, rung_str, desc])
+ self._sheet.set_sheet_data(data)
+ self._sheet.set_column_widths([30, 250, 45, 450])
+ self._update_highlights()
+
+ def _all_done(self) -> None:
+ self._current_idx = None
+ self._copied = False
+ self._update_highlights()
+ self._status_var.set("All CSVs have been pasted!")
+ self._action_btn.configure(state="disabled", text="Done")
+
+ # ------------------------------------------------------------------
+ # Action button state
+ # ------------------------------------------------------------------
+
+ def _update_action_btn(self) -> None:
+ """Update the main action button text based on current state."""
+ if self._current_idx is None:
+ self._action_btn.configure(state="disabled", text="Done")
+ return
+
+ if self._copied:
+ self._action_btn.configure(state="normal", text="Next \u2192")
+ else:
+ label = "Import" if self._is_nickname_step(self._current_idx) else "\U0001f4cb Copy"
+ self._action_btn.configure(state="normal", text=label)
+
+ # ------------------------------------------------------------------
+ # Clipboard / Import
+ # ------------------------------------------------------------------
+
+ def _do_import_nicknames(self, idx: int) -> None:
+ """Import nicknames.csv into MDB."""
+ _name, path, _rungs, _ = self._items[idx]
+ mdb_path = self._get_mdb_path()
+ if not mdb_path:
+ self._status_var.set(
+ "No Click database found.\n"
+ "Connect to a Click instance first, then click 'Re-import'."
+ )
+ return
+
+ result = import_nicknames_csv(path, mdb_path)
+ if result.error:
+ self._status_var.set(f"Import error: {result.error}")
+ return
+
+ self._copied = True
+ msg = f"Imported {result.rows_written} nickname(s) into MDB"
+ msg += '\nClick "Next" to continue to ladder CSVs'
+ self._status_var.set(msg)
+ self._update_action_btn()
+
+ def _do_copy_to_clipboard(self, idx: int) -> None:
+ """Copy a ladder CSV to the clipboard."""
+ name, path, _rungs, _ = self._items[idx]
+ try:
+ mdb_path = self._get_mdb_path()
+ click_hwnd = self._get_click_hwnd()
+ result = prepare_csv_load(
+ path, mdb_path=mdb_path, show_nicknames=self._show_nicks_var.get()
+ )
+ copy_to_clipboard(result.payload, owner_hwnd=click_hwnd)
+
+ self._copied = True
+ rung_word = "rung" if result.rung_count == 1 else "rungs"
+ msg = f"{name} copied to clipboard ({result.rung_count} {rung_word})"
+ if result.addresses_inserted:
+ msg += f"\nMDB: inserted {result.addresses_inserted} address(es)"
+ elif result.mdb_error:
+ msg += f"\nMDB: {result.mdb_error}"
+ msg += '\nPaste into CLICK (Ctrl+V on ladder screen), then click "Next"'
+ self._status_var.set(msg)
+ self._update_action_btn()
+ except RuntimeError as exc:
+ self._status_var.set(f"Clipboard error: {exc}\nIs Click running?")
+ except Exception as exc:
+ self._status_var.set(f"Error loading {name}: {exc}")
+
+ def _perform_action(self, idx: int) -> None:
+ """Perform the copy or import action for the given row."""
+ if self._is_nickname_step(idx):
+ self._do_import_nicknames(idx)
+ else:
+ self._do_copy_to_clipboard(idx)
+
+ def _select_row(self, idx: int) -> None:
+ if idx < 0 or idx >= len(self._items):
+ return
+ name = self._items[idx][0]
+ if name in self._done:
+ return
+ self._current_idx = idx
+ self._copied = False
+ self._update_highlights()
+ self._update_action_btn()
+
+ if self._is_nickname_step(idx):
+ self._status_var.set('Click "Import" to import nicknames into the Click database')
+ else:
+ self._status_var.set(f'Click "Copy" to copy {name} to the clipboard')
+
+ # ------------------------------------------------------------------
+ # Navigation
+ # ------------------------------------------------------------------
+
+ def _advance_to_first_pending(self) -> None:
+ for idx, (name, *_) in enumerate(self._items):
+ if name not in self._done:
+ self._select_row(idx)
+ return
+ self._all_done()
+
+ def _advance_to_next(self) -> None:
+ if self._current_idx is None:
+ self._advance_to_first_pending()
+ return
+ start = self._current_idx + 1
+ # Search forward from current, then wrap
+ for idx in list(range(start, len(self._items))) + list(range(0, start)):
+ name = self._items[idx][0]
+ if name not in self._done:
+ self._select_row(idx)
+ return
+ self._all_done()
+
+ # ------------------------------------------------------------------
+ # Event handlers
+ # ------------------------------------------------------------------
+
+ def _on_cell_select(self, _event: object) -> None:
+ selected = self._sheet.get_currently_selected()
+ if selected is None:
+ return
+ row = selected.row
+ if 0 <= row < len(self._items):
+ name = self._items[row][0]
+ if name not in self._done:
+ self._select_row(row)
+
+ def _on_action(self) -> None:
+ """Handle the main action button (Copy/Import or Next)."""
+ if self._current_idx is None:
+ return
+ if self._copied:
+ # "Next" — mark done and advance
+ name = self._items[self._current_idx][0]
+ self._done.add(name)
+ self._advance_to_next()
+ else:
+ # "Copy" or "Import" — perform the action
+ self._perform_action(self._current_idx)
+
+ def _on_skip(self) -> None:
+ if self._current_idx is None:
+ return
+ self._advance_to_next()
+
+ def _on_restart(self) -> None:
+ if self._done and not messagebox.askyesno(
+ "Restart",
+ f"Clear progress for {len(self._done)} completed CSV(s)?",
+ parent=self,
+ ):
+ return
+ self._done.clear()
+ self._current_idx = None
+ self._copied = False
+ self._action_btn.configure(state="normal")
+ self._populate_sheet()
+ self._advance_to_first_pending()
+
+ # ------------------------------------------------------------------
+ # Widget creation
+ # ------------------------------------------------------------------
+
+ def _create_widgets(self) -> None:
+ main = ttk.Frame(self, padding=10)
+ main.pack(fill=tk.BOTH, expand=True)
+
+ # --- header ---
+ header = ttk.Frame(main)
+ header.pack(fill=tk.X, pady=(0, 5))
+ ttk.Label(
+ header,
+ text=f"Guided Paste \u2014 {self._folder.name}/",
+ font=("TkDefaultFont", 10, "bold"),
+ ).pack(side=tk.LEFT)
+ self._progress_lbl = ttk.Label(header, text="")
+ self._progress_lbl.pack(side=tk.RIGHT)
+
+ # --- options row ---
+ opts = ttk.Frame(main)
+ opts.pack(fill=tk.X, pady=(0, 5))
+
+ if self._nickname_path:
+ ttk.Label(opts, text="nicknames.csv found", foreground="gray").pack(side=tk.LEFT)
+
+ self._show_nicks_var = tk.BooleanVar(value=True)
+ ttk.Checkbutton(
+ opts,
+ text="Show nicknames in math instructions",
+ variable=self._show_nicks_var,
+ ).pack(side=tk.RIGHT)
+
+ # --- tksheet ---
+ sheet_frame = tk.Frame(main, relief=tk.SUNKEN, borderwidth=2)
+ sheet_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 5))
+
+ self._sheet = Sheet(
+ sheet_frame,
+ headers=["", "File", "Rungs", "Description"],
+ show_row_index=False,
+ height=300,
+ width=690,
+ )
+ self._sheet.enable_bindings()
+ self._sheet.disable_bindings(
+ "column_drag_and_drop",
+ "row_drag_and_drop",
+ "rc_select_column",
+ "rc_insert_column",
+ "rc_delete_column",
+ "rc_insert_row",
+ "rc_delete_row",
+ "sort_cells",
+ "sort_row",
+ "sort_column",
+ "sort_rows",
+ "sort_columns",
+ "edit_cell",
+ "find",
+ "replace",
+ "undo",
+ "copy",
+ "cut",
+ "paste",
+ "delete",
+ )
+ # Suppress the right-click popup entirely on this read-only sheet
+ self._sheet.bind("", lambda e: "break")
+ self._sheet.pack(fill=tk.BOTH, expand=True)
+
+ self._sheet.extra_bindings("cell_select", self._on_cell_select)
+
+ # --- status label ---
+ self._status_var = tk.StringVar(value="Loading\u2026")
+ status_frame = ttk.LabelFrame(main, text="Instructions", padding=5)
+ status_frame.pack(fill=tk.X, pady=(0, 5))
+ ttk.Label(
+ status_frame,
+ textvariable=self._status_var,
+ wraplength=660,
+ foreground="gray",
+ ).pack(fill=tk.X)
+
+ # --- buttons ---
+ btn_frame = ttk.Frame(main)
+ btn_frame.pack(fill=tk.X)
+
+ ttk.Button(btn_frame, text="⟳ Restart", command=self._on_restart, width=10).pack(
+ side=tk.LEFT
+ )
+
+ self._action_btn = ttk.Button(
+ btn_frame, text="\U0001f4cb Copy", command=self._on_action, width=10, state="disabled"
+ )
+ self._action_btn.pack(side=tk.RIGHT, padx=(5, 0))
+
+ ttk.Button(btn_frame, text="Skip", command=self._on_skip, width=10).pack(side=tk.RIGHT)
+
+ def __init__(
+ self,
+ parent: tk.Widget,
+ folder: Path,
+ *,
+ get_mdb_path: Callable[[], Path | None],
+ get_click_hwnd: Callable[[], int | None],
+ ):
+ super().__init__(parent)
+ self.title(f"Guided Paste \u2014 {folder.name}")
+ self.geometry("820x520")
+ self.minsize(520, 400)
+
+ self._folder = folder
+ self._get_mdb_path = get_mdb_path
+ self._get_click_hwnd = get_click_hwnd
+
+ # (display_name, path, rung_count, description)
+ self._items: list[tuple[str, Path, int, str]] = []
+ self._done: set[str] = set()
+ self._current_idx: int | None = None
+ self._copied: bool = False
+ self._nickname_path: Path | None = None
+
+ self._scan_folder()
+ self._create_widgets()
+ self._populate_sheet()
+ self._advance_to_first_pending()
diff --git a/src/clicknick/views/nav_window/block_logic.py b/src/clicknick/views/nav_window/block_logic.py
index 034975a..06f4c2b 100644
--- a/src/clicknick/views/nav_window/block_logic.py
+++ b/src/clicknick/views/nav_window/block_logic.py
@@ -7,10 +7,11 @@
from __future__ import annotations
+import re
from collections.abc import Sequence
from dataclasses import dataclass
-from pyclickplc.blocks import BlockRange, group_udt_block_names, parse_structured_block_name
+from pyclickplc.blocks import BlockRange, parse_structured_block_name
from ...models.address_row import AddressRow
@@ -44,6 +45,11 @@ class _BlockEntry:
end_display: str
+_UDT_WITH_METADATA_RE = re.compile(
+ r"^(?P[A-Za-z_][A-Za-z0-9_]*)\.(?P[A-Za-z_][A-Za-z0-9_]*)(?P(?::.+|\s.+)?)$"
+)
+
+
def _format_with_range(label: str, start: str, end: str) -> str:
"""Format block text with single-point or range display."""
if start == end:
@@ -65,6 +71,21 @@ def _dedupe_addresses(
return tuple(result)
+def _parse_grouping_name(name: str) -> tuple[str, str | None, str]:
+ """Parse block name for tree grouping, preserving child metadata after ':' or space."""
+ parsed = parse_structured_block_name(name)
+ if parsed.kind == "udt" and parsed.field is not None:
+ return parsed.base, parsed.field, parsed.kind
+
+ metadata_match = _UDT_WITH_METADATA_RE.fullmatch(name)
+ if metadata_match is not None:
+ field = metadata_match.group("field")
+ metadata = metadata_match.group("meta") or ""
+ return metadata_match.group("base"), field + metadata, "udt"
+
+ return parsed.base, None, parsed.kind
+
+
def _build_entries(ranges: list[BlockRange], rows: Sequence[AddressRow]) -> list[_BlockEntry]:
"""Build normalized entries from matched block ranges."""
entries: list[_BlockEntry] = []
@@ -73,17 +94,16 @@ def _build_entries(ranges: list[BlockRange], rows: Sequence[AddressRow]) -> list
if not block_rows:
continue
- parsed = parse_structured_block_name(block.name)
- field = parsed.field if parsed.kind == "udt" else None
+ base, field, kind = _parse_grouping_name(block.name)
addresses = tuple((row.memory_type, row.address) for row in block_rows)
entries.append(
_BlockEntry(
idx=idx,
name=block.name,
- base=parsed.base,
+ base=base,
field=field,
- kind=parsed.kind,
+ kind=kind,
start_idx=block.start_idx,
bg_color=block.bg_color,
addresses=addresses,
@@ -110,25 +130,19 @@ def _build_udt_node(
base: str, entries: list[_BlockEntry], *, sort_alphabetically: bool
) -> BlockTreeNode:
"""Create one UDT parent node and its field children."""
- field_order_map = group_udt_block_names(entry.name for entry in entries)
- field_order = list(field_order_map.get(base, ()))
-
by_field: dict[str, list[_BlockEntry]] = {}
- fallback_field_order: list[str] = []
+ field_order: list[str] = []
for entry in entries:
if entry.field is None:
continue
by_field.setdefault(entry.field, []).append(entry)
- if entry.field not in fallback_field_order:
- fallback_field_order.append(entry.field)
+ if entry.field not in field_order:
+ field_order.append(entry.field)
if sort_alphabetically:
ordered_fields = sorted(by_field.keys(), key=str.lower)
else:
- seen = set(field_order)
- ordered_fields = field_order + [
- field for field in fallback_field_order if field not in seen
- ]
+ ordered_fields = field_order
children: list[BlockTreeNode] = []
for field in ordered_fields:
diff --git a/tests/fixtures/ladder_captures/golden/instr-6row-multi-output.bin b/tests/fixtures/ladder_captures/golden/instr-6row-multi-output.bin
new file mode 100644
index 0000000..2cc4150
Binary files /dev/null and b/tests/fixtures/ladder_captures/golden/instr-6row-multi-output.bin differ
diff --git a/tests/fixtures/ladder_captures/golden/instr-6row-multi-output.csv b/tests/fixtures/ladder_captures/golden/instr-6row-multi-output.csv
new file mode 100644
index 0000000..db599eb
--- /dev/null
+++ b/tests/fixtures/ladder_captures/golden/instr-6row-multi-output.csv
@@ -0,0 +1,8 @@
+marker,A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z,AA,AB,AC,AD,AE,AF
+#,Return early if not in range
+R,DS181<1,DS181>100,T,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,"copy(0,DS181)"
+,,,|,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
+,,,T,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,reset(C1061)
+,,,T,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,"copy(0,DS182)"
+,,,|,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
+,,,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,return()
diff --git a/tests/ladder/__init__.py b/tests/ladder/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/ladder/test_cli.py b/tests/ladder/test_cli.py
new file mode 100644
index 0000000..21fcf6a
--- /dev/null
+++ b/tests/ladder/test_cli.py
@@ -0,0 +1,534 @@
+"""Tests for ladder CLI and program service functions."""
+
+from __future__ import annotations
+
+import typing
+from pathlib import Path
+
+import pytest
+from laddercodec import Coil, CompareContact, Contact, Timer
+from laddercodec.csv.contract import CONDITION_COLUMNS
+from laddercodec.model import Program
+
+from clicknick.ladder.cli import (
+ _append_result,
+ _read_progress,
+ _run_guided,
+ main,
+)
+from clicknick.ladder.program import (
+ _collapse_cols,
+ _dedupe_filename_stem,
+ _describe_row_wires,
+ _describe_single_rung,
+ _extract_operand_candidates,
+ _slugify,
+ program_save,
+)
+
+# Resolve the InstructionType enum used by Contact/Coil constructors
+_IT = typing.get_type_hints(Contact)["type"]
+
+
+# ---------------------------------------------------------------------------
+# _extract_operand_candidates
+# ---------------------------------------------------------------------------
+
+
+class TestExtractOperandCandidates:
+ def test_contact(self):
+ assert _extract_operand_candidates(Contact(_IT.CONTACT_NO, "X001")) == ["X001"]
+
+ def test_coil_with_range(self):
+ assert _extract_operand_candidates(Coil(_IT.COIL_OUT, "Y001", range_end="Y005")) == [
+ "Y001",
+ "Y005",
+ ]
+
+ def test_coil_without_range(self):
+ assert _extract_operand_candidates(Coil(_IT.COIL_OUT, "Y001", range_end=None)) == [
+ "Y001",
+ ]
+
+ def test_compare_contact(self):
+ result = _extract_operand_candidates(CompareContact("==", "DS1", "100"))
+ assert result == ["DS1", "100"]
+
+ def test_timer(self):
+ assert _extract_operand_candidates(Timer("on_delay", "T1", "TD1", "1000", "ms")) == [
+ "T1",
+ "TD1",
+ ]
+
+ def test_plain_string(self):
+ assert _extract_operand_candidates("X001") == ["X001"]
+
+ def test_plain_string_with_range(self):
+ result = _extract_operand_candidates("X001 .. X005")
+ # Range regex captures both ends, then token regex also captures both
+ assert "X001" in result
+ assert "X005" in result
+
+ def test_empty_string(self):
+ assert _extract_operand_candidates("") == []
+
+
+# ---------------------------------------------------------------------------
+# _collapse_cols
+# ---------------------------------------------------------------------------
+
+
+class TestCollapseCols:
+ def test_contiguous_run_of_3(self):
+ result = _collapse_cols(["A", "B", "C"], CONDITION_COLUMNS)
+ assert result == "A..C"
+
+ def test_short_runs(self):
+ result = _collapse_cols(["A", "B"], CONDITION_COLUMNS)
+ assert result == "A+B"
+
+ def test_mixed(self):
+ # A is isolated, C..E is a run of 3, G is isolated
+ result = _collapse_cols(["A", "C", "D", "E", "G"], CONDITION_COLUMNS)
+ assert result == "A+C..E+G"
+
+ def test_empty(self):
+ assert _collapse_cols([], CONDITION_COLUMNS) == ""
+
+ def test_single(self):
+ assert _collapse_cols(["A"], CONDITION_COLUMNS) == "A"
+
+ def test_long_contiguous(self):
+ cols = list(CONDITION_COLUMNS) # all 31
+ result = _collapse_cols(cols, CONDITION_COLUMNS)
+ assert result == "A..AE"
+
+
+# ---------------------------------------------------------------------------
+# _describe_row_wires
+# ---------------------------------------------------------------------------
+
+
+class TestDescribeRowWires:
+ def test_all_dashes(self):
+ row = ["-"] * len(CONDITION_COLUMNS)
+ assert _describe_row_wires(row, CONDITION_COLUMNS) == "full"
+
+ def test_mixed_tokens(self):
+ row = ["-"] * len(CONDITION_COLUMNS)
+ row[1] = "T" # column B
+ result = _describe_row_wires(row, CONDITION_COLUMNS)
+ assert "T:B" in result
+ assert "-:" in result
+
+ def test_no_wire_tokens(self):
+ row = ["Contact(X1)"] * len(CONDITION_COLUMNS)
+ assert _describe_row_wires(row, CONDITION_COLUMNS) == ""
+
+
+# ---------------------------------------------------------------------------
+# _describe_single_rung
+# ---------------------------------------------------------------------------
+
+
+class TestDescribeSingleRung:
+ def test_single_empty_row(self):
+ result = _describe_single_rung([["-"] * 31], ["-"], None, CONDITION_COLUMNS)
+ assert result.startswith("1 row")
+
+ def test_with_comment(self):
+ result = _describe_single_rung([["-"] * 31], ["-"], "my comment", CONDITION_COLUMNS)
+ assert 'comment "my comment"' in result
+
+ def test_long_comment(self):
+ long = "x" * 60
+ result = _describe_single_rung([["-"] * 31], ["-"], long, CONDITION_COLUMNS)
+ assert "comment (60 chars)" in result
+
+ def test_with_af_tokens(self):
+ result = _describe_single_rung([["-"] * 31], ["Coil(Y001)"], None, CONDITION_COLUMNS)
+ assert "AF=Coil(Y001)" in result
+
+ def test_multiple_rows(self):
+ result = _describe_single_rung(
+ [["-"] * 31, ["-"] * 31], ["-", "-"], None, CONDITION_COLUMNS
+ )
+ assert "2 rows" in result
+
+
+# ---------------------------------------------------------------------------
+# _read_progress / _append_result
+# ---------------------------------------------------------------------------
+
+
+class TestProgressLog:
+ def test_round_trip(self, tmp_path: Path):
+ log = tmp_path / "progress.log"
+ _append_result(log, "fixture1", "worked")
+ _append_result(log, "fixture2", "crashed", "segfault")
+
+ done = _read_progress(log)
+ assert "fixture1" in done
+ assert "fixture2" in done
+ assert "worked" in done["fixture1"]
+ assert "crashed" in done["fixture2"]
+ assert "segfault" in done["fixture2"]
+
+ def test_skips_comments_and_blanks(self, tmp_path: Path):
+ log = tmp_path / "progress.log"
+ log.write_text("# header\n\nfixture1: worked\n", encoding="utf-8")
+
+ done = _read_progress(log)
+ assert list(done.keys()) == ["fixture1"]
+
+ def test_append_preserves_existing(self, tmp_path: Path):
+ log = tmp_path / "progress.log"
+ log.write_text("# header\nfixture1: worked\n", encoding="utf-8")
+ _append_result(log, "fixture2", "skipped")
+
+ text = log.read_text(encoding="utf-8")
+ assert "fixture1: worked" in text
+ assert "fixture2: skipped" in text
+
+ def test_nonexistent_log(self, tmp_path: Path):
+ log = tmp_path / "no_such_file.log"
+ assert _read_progress(log) == {}
+
+ def test_append_adds_newline_if_missing(self, tmp_path: Path):
+ log = tmp_path / "progress.log"
+ log.write_text("fixture1: worked", encoding="utf-8") # no trailing newline
+ _append_result(log, "fixture2", "crashed", "segfault")
+
+ done = _read_progress(log)
+ assert "fixture1" in done
+ assert "fixture2" in done
+
+
+# ---------------------------------------------------------------------------
+# Filename sanitizing
+# ---------------------------------------------------------------------------
+
+
+class TestProgramFilenameSanitizing:
+ def test_slugify_preserves_case(self):
+ assert _slugify("ModeManualTimers") == "ModeManualTimers"
+
+ def test_slugify_preserves_spaces_and_hyphens(self):
+ assert _slugify("Mode Manual-Timers") == "Mode Manual-Timers"
+
+ def test_slugify_removes_windows_unsafe_chars(self):
+ assert _slugify('Bad<>:"/\\|?*Name') == "BadName"
+
+ def test_slugify_collapses_repeated_spaces_and_trims_trailing_space_dot(self):
+ assert _slugify("Mode Manual. ") == "Mode Manual"
+
+ def test_slugify_falls_back_when_name_becomes_empty(self):
+ assert _slugify(' <>:"/\\|?*. ') == "Untitled"
+
+ def test_dedupe_filename_stem_appends_windows_style_suffix(self):
+ used: set[str] = set()
+ assert _dedupe_filename_stem("ModeManualTimers", used) == "ModeManualTimers"
+ assert _dedupe_filename_stem("ModeManualTimers", used) == "ModeManualTimers (2)"
+ assert _dedupe_filename_stem("ModeManualTimers", used) == "ModeManualTimers (3)"
+
+ def test_dedupe_filename_stem_treats_case_only_difference_as_duplicate(self):
+ used: set[str] = set()
+ assert _dedupe_filename_stem("ModeManualTimers", used) == "ModeManualTimers"
+ assert _dedupe_filename_stem("modemanualtimers", used) == "modemanualtimers (2)"
+
+
+# ---------------------------------------------------------------------------
+# Program save
+# ---------------------------------------------------------------------------
+
+
+class TestProgramSave:
+ def test_program_save_preserves_case_spacing_and_dedupes(self, tmp_path: Path, monkeypatch):
+ src = tmp_path / "src"
+ src.mkdir()
+ for name in ("Scr1.tmp", "Scr10.tmp", "Scr11.tmp", "Scr12.tmp", "Scr13.tmp", "Scr14.tmp"):
+ (src / name).write_bytes(b"scr")
+
+ programs = iter(
+ [
+ Program(name="Main Program", prog_idx=1, rungs=[]),
+ Program(name="ModeManualTimers", prog_idx=10, rungs=[]),
+ Program(name="Mode Manual Timers", prog_idx=11, rungs=[]),
+ Program(name="Mode-Manual-Timers", prog_idx=12, rungs=[]),
+ Program(name='Mode<>:"/\\|?*ManualTimers', prog_idx=13, rungs=[]),
+ Program(name="ModeManualTimers.", prog_idx=14, rungs=[]),
+ ]
+ )
+
+ monkeypatch.setattr("clicknick.ladder.program.decode_program", lambda data: next(programs))
+
+ written_paths: list[Path] = []
+
+ def _write_csv(path: Path, rungs) -> None:
+ written_paths.append(Path(path))
+
+ monkeypatch.setattr("clicknick.ladder.program.write_csv", _write_csv)
+
+ out = tmp_path / "out"
+ result = program_save(src, out)
+
+ assert written_paths == [
+ out / "main.csv",
+ out / "subroutines" / "ModeManualTimers.csv",
+ out / "subroutines" / "Mode Manual Timers.csv",
+ out / "subroutines" / "Mode-Manual-Timers.csv",
+ out / "subroutines" / "ModeManualTimers (2).csv",
+ out / "subroutines" / "ModeManualTimers (3).csv",
+ ]
+
+ assert result.main_csv == out / "main.csv"
+ assert len(result.subroutine_csvs) == 5
+ assert result.total_rungs == 0
+ assert len(result.programs) == 6
+
+
+# ---------------------------------------------------------------------------
+# Guided program mode
+# ---------------------------------------------------------------------------
+
+
+class TestGuidedProgramMode:
+ def test_run_guided_without_copyback_marks_worked_without_bin_roundtrip(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ):
+ csv_path = tmp_path / "sub.csv"
+ log_path = tmp_path / "progress.log"
+
+ monkeypatch.setattr("clicknick.ladder.cli._save_and_compare", lambda *args: pytest.fail())
+ monkeypatch.setattr(
+ "clicknick.ladder.cli.read_from_clipboard",
+ lambda: pytest.fail(),
+ )
+
+ responses = iter(["w"])
+ monkeypatch.setattr("builtins.input", lambda _prompt="": next(responses))
+
+ with pytest.raises(SystemExit) as excinfo:
+ _run_guided(
+ [("sub", csv_path)],
+ log_path=log_path,
+ loader=lambda _path, _mdb_path: b"",
+ save_copyback_bin=False,
+ )
+
+ assert excinfo.value.code == 0
+ assert "sub: worked" in log_path.read_text(encoding="utf-8")
+ assert not csv_path.with_suffix(".bin").exists()
+
+ def test_run_guided_without_copyback_skips_unexpected_bin_save_prompt(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ):
+ csv_path = tmp_path / "sub.csv"
+ log_path = tmp_path / "progress.log"
+
+ monkeypatch.setattr(
+ "clicknick.ladder.cli.read_from_clipboard",
+ lambda: pytest.fail(),
+ )
+
+ responses = iter(["n", "wrong branch"])
+ monkeypatch.setattr("builtins.input", lambda _prompt="": next(responses))
+
+ with pytest.raises(SystemExit) as excinfo:
+ _run_guided(
+ [("sub", csv_path)],
+ log_path=log_path,
+ loader=lambda _path, _mdb_path: b"",
+ save_copyback_bin=False,
+ )
+
+ assert excinfo.value.code == 1
+ assert "sub: unexpected (wrong branch)" in log_path.read_text(encoding="utf-8")
+ assert not csv_path.with_suffix(".bin").exists()
+
+ def test_program_load_routes_with_copyback_disabled(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ):
+ (tmp_path / "main.csv").write_text("dummy", encoding="utf-8")
+
+ captured: dict[str, object] = {}
+
+ def _capture(*args, **kwargs):
+ captured["args"] = args
+ captured["kwargs"] = kwargs
+
+ monkeypatch.setattr("clicknick.ladder.cli._run_guided", _capture)
+ monkeypatch.setattr("sys.argv", ["clicknick-rung", "program", "load", str(tmp_path)])
+
+ main()
+
+ assert captured["kwargs"]["save_copyback_bin"] is False
+
+
+# ---------------------------------------------------------------------------
+# CLI routing (main)
+# ---------------------------------------------------------------------------
+
+
+class TestMainArgparse:
+ """Test CLI argument parsing and routing with mocked clipboard."""
+
+ def test_save_writes_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ out = tmp_path / "output.bin"
+ monkeypatch.setattr(
+ "clicknick.ladder.cli.read_from_clipboard",
+ lambda: b"\x01\x02\x03",
+ )
+ monkeypatch.setattr("sys.argv", ["clicknick-rung", "save", str(out)])
+ main()
+ assert out.read_bytes() == b"\x01\x02\x03"
+
+ def test_save_csv_decodes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ out = tmp_path / "output.csv"
+ monkeypatch.setattr(
+ "clicknick.ladder.cli.read_from_clipboard",
+ lambda: b"\x01\x02\x03",
+ )
+ decoded_calls: list[tuple[bytes, Path]] = []
+
+ def mock_decode_to_csv(data, path):
+ decoded_calls.append((data, path))
+ Path(path).write_text("decoded", encoding="utf-8")
+
+ monkeypatch.setattr("clicknick.ladder.cli.decode_to_csv", mock_decode_to_csv)
+ monkeypatch.setattr("sys.argv", ["clicknick-rung", "save", str(out)])
+ main()
+ assert len(decoded_calls) == 1
+ assert decoded_calls[0] == (b"\x01\x02\x03", out)
+ assert not out.with_suffix(".bin").exists()
+
+ def test_save_csv_decode_error(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
+ ):
+ out = tmp_path / "output.csv"
+ monkeypatch.setattr(
+ "clicknick.ladder.cli.read_from_clipboard",
+ lambda: b"\x01\x02\x03",
+ )
+
+ def mock_decode_to_csv(data, path):
+ from laddercodec.csv.writer import WriterError
+
+ raise WriterError("unknown instruction")
+
+ monkeypatch.setattr("clicknick.ladder.cli.decode_to_csv", mock_decode_to_csv)
+ monkeypatch.setattr("sys.argv", ["clicknick-rung", "save", str(out)])
+ with pytest.raises(SystemExit, match="1"):
+ main()
+ assert "could not decode to CSV" in capsys.readouterr().err
+
+ def test_save_no_extension_writes_both(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ out = tmp_path / "my_rung"
+ monkeypatch.setattr(
+ "clicknick.ladder.cli.read_from_clipboard",
+ lambda: b"\x01\x02\x03",
+ )
+
+ def mock_decode_to_csv(data, path):
+ Path(path).write_text("decoded", encoding="utf-8")
+
+ monkeypatch.setattr("clicknick.ladder.cli.decode_to_csv", mock_decode_to_csv)
+ monkeypatch.setattr("sys.argv", ["clicknick-rung", "save", str(out)])
+ main()
+ assert out.with_suffix(".bin").read_bytes() == b"\x01\x02\x03"
+ assert out.with_suffix(".csv").read_text(encoding="utf-8") == "decoded"
+
+ def test_save_no_extension_csv_failure_still_saves_bin(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
+ ):
+ out = tmp_path / "my_rung"
+ monkeypatch.setattr(
+ "clicknick.ladder.cli.read_from_clipboard",
+ lambda: b"\x01\x02\x03",
+ )
+
+ def mock_decode_to_csv(data, path):
+ from laddercodec.csv.writer import WriterError
+
+ raise WriterError("unknown instruction")
+
+ monkeypatch.setattr("clicknick.ladder.cli.decode_to_csv", mock_decode_to_csv)
+ monkeypatch.setattr("sys.argv", ["clicknick-rung", "save", str(out)])
+ main()
+ assert out.with_suffix(".bin").read_bytes() == b"\x01\x02\x03"
+ assert not out.with_suffix(".csv").exists()
+ assert "could not write CSV" in capsys.readouterr().err
+
+ def test_save_clipboard_error(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
+ ):
+ out = tmp_path / "output.bin"
+
+ def _raise():
+ raise RuntimeError("No Click rung data on clipboard (format 522 not present).")
+
+ monkeypatch.setattr("clicknick.ladder.cli.read_from_clipboard", _raise)
+ monkeypatch.setattr("sys.argv", ["clicknick-rung", "save", str(out)])
+ with pytest.raises(SystemExit, match="1"):
+ main()
+ assert "No Click rung data" in capsys.readouterr().err
+
+ def test_load_bin_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ src = tmp_path / "rung.bin"
+ src.write_bytes(b"\xaa\xbb")
+ copied: list[bytes] = []
+ monkeypatch.setattr(
+ "clicknick.ladder.cli.copy_to_clipboard",
+ lambda data: copied.append(data),
+ )
+ monkeypatch.setattr("sys.argv", ["clicknick-rung", "load", str(src)])
+ main()
+ assert copied == [b"\xaa\xbb"]
+
+ def test_load_csv_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ csv_file = tmp_path / "rung.csv"
+ csv_file.write_text("dummy", encoding="utf-8")
+ called: list[Path] = []
+
+ def mock_load_csv(path, mdb_path, best_effort=False):
+ called.append(path)
+ return b""
+
+ monkeypatch.setattr("clicknick.ladder.cli._load_csv", mock_load_csv)
+ monkeypatch.setattr("sys.argv", ["clicknick-rung", "load", str(csv_file)])
+ main()
+ assert called == [csv_file]
+
+ def test_load_clipboard_error(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
+ ):
+ src = tmp_path / "rung.bin"
+ src.write_bytes(b"\xaa")
+
+ def _raise(data):
+ raise RuntimeError("Click Programming Software not found. Is it running?")
+
+ monkeypatch.setattr("clicknick.ladder.cli.copy_to_clipboard", _raise)
+ monkeypatch.setattr("sys.argv", ["clicknick-rung", "load", str(src)])
+ with pytest.raises(SystemExit, match="1"):
+ main()
+ assert "Click Programming Software not found" in capsys.readouterr().err
+
+ def test_guided_list(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ csv_file = tmp_path / "test.csv"
+ csv_file.write_text("dummy", encoding="utf-8")
+
+ monkeypatch.setattr(
+ "clicknick.ladder.cli.describe_csv",
+ lambda p: "1 row, full",
+ )
+ monkeypatch.setattr(
+ "sys.argv",
+ ["clicknick-rung", "guided", str(tmp_path), "--list"],
+ )
+ main()
+
+ def test_no_subcommand(self, monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.setattr("sys.argv", ["clicknick-rung"])
+ with pytest.raises(SystemExit, match="1"):
+ main()
diff --git a/tests/test_block_logic.py b/tests/test_block_logic.py
index ec07082..5c8dbb2 100644
--- a/tests/test_block_logic.py
+++ b/tests/test_block_logic.py
@@ -52,6 +52,56 @@ def test_udt_names_group_under_base_parent(self):
assert not pump.is_group
assert pump.text == f"Pump ({rows[2].display_address})"
+ def test_udt_names_with_metadata_group_under_base_parent(self):
+ rows = _make_rows([("X", 1), ("X", 2), ("X", 3), ("X", 4)])
+ ranges = [
+ BlockRange(0, 0, "Parent.Childs", None, "X"),
+ BlockRange(1, 1, "Parent.Clothes:meta", None, "X"),
+ BlockRange(2, 2, "Parent.Cars", None, "X"),
+ BlockRange(3, 3, "Pump", None, "X"),
+ ]
+
+ nodes = build_block_tree(ranges, rows, sort_alphabetically=False)
+
+ assert len(nodes) == 2
+ parent = nodes[0]
+ assert parent.is_group
+ assert parent.text == "Parent"
+ assert [child.text for child in parent.children] == [
+ f"Childs ({rows[0].display_address})",
+ f"Clothes:meta ({rows[1].display_address})",
+ f"Cars ({rows[2].display_address})",
+ ]
+
+ pump = nodes[1]
+ assert not pump.is_group
+ assert pump.text == f"Pump ({rows[3].display_address})"
+
+ def test_udt_names_with_space_flags_group_under_base_parent(self):
+ rows = _make_rows([("X", 1), ("X", 2), ("X", 3), ("X", 4)])
+ ranges = [
+ BlockRange(0, 0, "Custom.Childs", None, "X"),
+ BlockRange(1, 1, "Custom.TasksStatus READONLY", None, "X"),
+ BlockRange(2, 2, "Custom.Cars", None, "X"),
+ BlockRange(3, 3, "Pump", None, "X"),
+ ]
+
+ nodes = build_block_tree(ranges, rows, sort_alphabetically=False)
+
+ assert len(nodes) == 2
+ custom = nodes[0]
+ assert custom.is_group
+ assert custom.text == "Custom"
+ assert [child.text for child in custom.children] == [
+ f"Childs ({rows[0].display_address})",
+ f"TasksStatus READONLY ({rows[1].display_address})",
+ f"Cars ({rows[2].display_address})",
+ ]
+
+ pump = nodes[1]
+ assert not pump.is_group
+ assert pump.text == f"Pump ({rows[3].display_address})"
+
def test_unsorted_top_level_and_child_order_follow_first_occurrence(self):
rows = _make_rows([("X", 1), ("X", 2), ("X", 3), ("X", 4), ("X", 5)])
ranges = [
diff --git a/tests/test_block_service.py b/tests/test_block_service.py
index 4a05f2c..8b73528 100644
--- a/tests/test_block_service.py
+++ b/tests/test_block_service.py
@@ -439,9 +439,22 @@ def test_transform_block_name_t_to_td():
# Base name gets _D suffix
assert _transform_block_name_for_pair("Pumps", "T", "TD") == "Pumps_D"
assert _transform_block_name_for_pair("Motors", "CT", "CTD") == "Motors_D"
+ assert _transform_block_name_for_pair("Timer:block", "T", "TD") == "Timer_D:block"
+ assert _transform_block_name_for_pair("Alarm.Run", "T", "TD") == "Alarm.Run_D"
+ assert _transform_block_name_for_pair("Alarm.Run:meta", "T", "TD") == "Alarm.Run_D:meta"
+ assert (
+ _transform_block_name_for_pair("Custom.TasksStatus READONLY", "T", "TD")
+ == "Custom.TasksStatus_D READONLY"
+ )
# Already has _D suffix - keep it
assert _transform_block_name_for_pair("Pumps_D", "T", "TD") == "Pumps_D"
+ assert _transform_block_name_for_pair("Timer_D:block", "T", "TD") == "Timer_D:block"
+ assert _transform_block_name_for_pair("Alarm.Run_D", "T", "TD") == "Alarm.Run_D"
+ assert (
+ _transform_block_name_for_pair("Custom.TasksStatus_D READONLY", "T", "TD")
+ == "Custom.TasksStatus_D READONLY"
+ )
def test_transform_block_name_td_to_t():
@@ -451,9 +464,18 @@ def test_transform_block_name_td_to_t():
# Remove _D suffix
assert _transform_block_name_for_pair("Pumps_D", "TD", "T") == "Pumps"
assert _transform_block_name_for_pair("Motors_D", "CTD", "CT") == "Motors"
+ assert _transform_block_name_for_pair("Timer_D:block", "TD", "T") == "Timer:block"
+ assert _transform_block_name_for_pair("Alarm.Run_D", "TD", "T") == "Alarm.Run"
+ assert _transform_block_name_for_pair("Alarm.Run_D:meta", "TD", "T") == "Alarm.Run:meta"
+ assert (
+ _transform_block_name_for_pair("Custom.TasksStatus_D READONLY", "TD", "T")
+ == "Custom.TasksStatus READONLY"
+ )
# No _D suffix - keep as is
assert _transform_block_name_for_pair("Pumps", "TD", "T") == "Pumps"
+ assert _transform_block_name_for_pair("Timer:block", "TD", "T") == "Timer:block"
+ assert _transform_block_name_for_pair("Alarm.Run", "TD", "T") == "Alarm.Run"
def test_interleaved_pair_sync_adds_suffix(store):
@@ -473,6 +495,48 @@ def test_interleaved_pair_sync_adds_suffix(store):
assert "" in td1_row.comment
+def test_interleaved_pair_sync_adds_suffix_before_metadata(store):
+ """Structured metadata should stay after the transformed base name."""
+ from pyclickplc.addresses import get_addr_key
+
+ t1_key = get_addr_key("T", 1)
+ td1_key = get_addr_key("TD", 1)
+
+ with store.edit_session("Add structured block on T") as session:
+ session.set_field(t1_key, "comment", "")
+
+ td1_row = store.visible_state[td1_key]
+ assert "" in td1_row.comment
+
+
+def test_interleaved_pair_sync_adds_suffix_after_dotted_name(store):
+ """Dotted names stay intact and get the suffix at the end of the name."""
+ from pyclickplc.addresses import get_addr_key
+
+ t1_key = get_addr_key("T", 1)
+ td1_key = get_addr_key("TD", 1)
+
+ with store.edit_session("Add dotted block on T") as session:
+ session.set_field(t1_key, "comment", '')
+
+ td1_row = store.visible_state[td1_key]
+ assert '' in td1_row.comment
+
+
+def test_interleaved_pair_sync_adds_suffix_before_dotted_flag(store):
+ """Trailing flags on dotted names should remain after the transformed name."""
+ from pyclickplc.addresses import get_addr_key
+
+ t1_key = get_addr_key("T", 1)
+ td1_key = get_addr_key("TD", 1)
+
+ with store.edit_session("Add flagged dotted block on T") as session:
+ session.set_field(t1_key, "comment", '')
+
+ td1_row = store.visible_state[td1_key]
+ assert '' in td1_row.comment
+
+
def test_interleaved_pair_sync_removes_suffix(store):
"""Test that TD → T block sync removes _D suffix."""
from pyclickplc.addresses import get_addr_key
@@ -489,6 +553,48 @@ def test_interleaved_pair_sync_removes_suffix(store):
assert "" in t1_row.comment
+def test_interleaved_pair_sync_removes_suffix_before_metadata(store):
+ """Structured metadata should survive TD → T sync without moving suffixes."""
+ from pyclickplc.addresses import get_addr_key
+
+ t1_key = get_addr_key("T", 1)
+ td1_key = get_addr_key("TD", 1)
+
+ with store.edit_session("Add structured block on TD") as session:
+ session.set_field(td1_key, "comment", "")
+
+ t1_row = store.visible_state[t1_key]
+ assert "" in t1_row.comment
+
+
+def test_interleaved_pair_sync_removes_suffix_after_dotted_name(store):
+ """Dotted names should remove the suffix from the full name, not the parent."""
+ from pyclickplc.addresses import get_addr_key
+
+ t1_key = get_addr_key("T", 1)
+ td1_key = get_addr_key("TD", 1)
+
+ with store.edit_session("Add dotted block on TD") as session:
+ session.set_field(td1_key, "comment", '')
+
+ t1_row = store.visible_state[t1_key]
+ assert '' in t1_row.comment
+
+
+def test_interleaved_pair_sync_removes_suffix_before_dotted_flag(store):
+ """Trailing flags on dotted names should survive TD -> T sync."""
+ from pyclickplc.addresses import get_addr_key
+
+ t1_key = get_addr_key("T", 1)
+ td1_key = get_addr_key("TD", 1)
+
+ with store.edit_session("Add flagged dotted block on TD") as session:
+ session.set_field(td1_key, "comment", '')
+
+ t1_row = store.visible_state[t1_key]
+ assert '' in t1_row.comment
+
+
def test_interleaved_pair_sync_closing_tag_with_suffix(store):
"""Test that closing tags also get _D suffix transformation."""
from pyclickplc.addresses import get_addr_key
diff --git a/tests/test_dataview_modbus_integration.py b/tests/test_dataview_modbus_integration.py
index 5a66f30..21c5aaf 100644
--- a/tests/test_dataview_modbus_integration.py
+++ b/tests/test_dataview_modbus_integration.py
@@ -166,7 +166,7 @@ def __init__(self):
self.write_calls: list[list[tuple[str, object]]] = []
self.write_result = [{"address": "X001", "ok": True, "error": None}]
- def connect(self, host, port):
+ def connect(self, host, port, **kwargs):
self.connect_calls.append((host, port))
def set_poll_addresses(self, addresses):
diff --git a/tests/test_mdb_operations.py b/tests/test_mdb_operations.py
new file mode 100644
index 0000000..f72dbe6
--- /dev/null
+++ b/tests/test_mdb_operations.py
@@ -0,0 +1,88 @@
+"""Tests for clicknick.utils.mdb_operations helpers."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+from pyclickplc.addresses import get_addr_key
+from pyclickplc.banks import DataType
+
+from clicknick.models.address_row import AddressRow
+from clicknick.utils import mdb_operations
+
+
+class _DummyConnection:
+ def __init__(self, db_path: str):
+ self.db_path = db_path
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, _exc_type, _exc_val, _exc_tb) -> None:
+ return None
+
+
+def test_ensure_addresses_exist_inserts_missing_only_and_dedupes(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ db_path = tmp_path / "SC_.mdb"
+ db_path.write_bytes(b"")
+
+ existing = {
+ get_addr_key("X", 1): AddressRow(memory_type="X", address=1, used=True),
+ }
+ inserted_rows: list[AddressRow] = []
+
+ monkeypatch.setattr(mdb_operations, "MdbConnection", _DummyConnection)
+ monkeypatch.setattr(mdb_operations, "load_all_addresses", lambda _conn: existing)
+ monkeypatch.setattr(
+ mdb_operations,
+ "save_changes",
+ lambda _conn, rows: inserted_rows.extend(rows) or len(rows),
+ )
+
+ result = mdb_operations.ensure_addresses_exist(str(db_path), ["x1", "X001", "Y001", "y1"])
+
+ assert result["requested_count"] == 2
+ assert result["inserted_count"] == 1
+ assert result["existing_count"] == 1
+ assert result["parsed_addresses"] == ["X001", "Y001"]
+ assert len(inserted_rows) == 1
+ row = inserted_rows[0]
+ assert row.memory_type == "Y"
+ assert row.address == 1
+ assert row.used is True
+ assert row.data_type == DataType.BIT
+
+
+def test_ensure_addresses_exist_expands_range_endpoints(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ db_path = tmp_path / "SC_.mdb"
+ db_path.write_bytes(b"")
+ inserted_rows: list[AddressRow] = []
+
+ monkeypatch.setattr(mdb_operations, "MdbConnection", _DummyConnection)
+ monkeypatch.setattr(mdb_operations, "load_all_addresses", lambda _conn: {})
+ monkeypatch.setattr(
+ mdb_operations,
+ "save_changes",
+ lambda _conn, rows: inserted_rows.extend(rows) or len(rows),
+ )
+
+ result = mdb_operations.ensure_addresses_exist(str(db_path), ["Y001..Y005", "Y005"])
+
+ assert result["requested_count"] == 2
+ assert result["inserted_count"] == 2
+ assert result["existing_count"] == 0
+ assert result["parsed_addresses"] == ["Y001", "Y005"]
+ assert {(row.memory_type, row.address) for row in inserted_rows} == {("Y", 1), ("Y", 5)}
+
+
+def test_ensure_addresses_exist_requires_existing_db_path(tmp_path: Path) -> None:
+ missing = tmp_path / "missing.mdb"
+ with pytest.raises(FileNotFoundError, match="MDB file not found"):
+ mdb_operations.ensure_addresses_exist(str(missing), ["X001"])
diff --git a/uv.lock b/uv.lock
index e2ebc43..786be8a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2,51 +2,49 @@ version = 1
revision = 3
requires-python = ">=3.11, <4.0"
+[options]
+exclude-newer = "2026-03-26T14:48:46.1163265Z"
+exclude-newer-span = "P7D"
+
+[options.exclude-newer-package]
+laddercodec = { timestamp = "2026-04-02T14:48:46.1163497Z", span = "PT0S" }
+pyrung = { timestamp = "2026-04-02T14:48:46.1163502Z", span = "PT0S" }
+pyclickplc = { timestamp = "2026-04-02T14:48:46.1163436Z", span = "PT0S" }
+
[[package]]
name = "clicknick"
source = { editable = "." }
dependencies = [
+ { name = "laddercodec" },
{ name = "pyclickplc" },
{ name = "pyodbc" },
+ { name = "pyrung" },
{ name = "pywin32" },
{ name = "tksheet" },
]
[package.dev-dependencies]
dev = [
- { name = "codespell" },
- { name = "funlog" },
{ name = "pytest" },
- { name = "rich" },
{ name = "ruff" },
{ name = "ssort" },
]
[package.metadata]
requires-dist = [
+ { name = "laddercodec" },
{ name = "pyclickplc" },
{ name = "pyodbc", specifier = ">=5.3.0" },
+ { name = "pyrung" },
{ name = "pywin32", specifier = ">=311" },
{ name = "tksheet", specifier = "==7.5.19" },
]
[package.metadata.requires-dev]
dev = [
- { name = "codespell", specifier = ">=2.4.1" },
- { name = "funlog", specifier = ">=0.2.0" },
{ name = "pytest", specifier = ">=8.3.5" },
- { name = "rich", specifier = ">=13.9.4" },
{ name = "ruff", specifier = ">=0.11.0" },
- { name = "ssort", specifier = ">=0.14.0" },
-]
-
-[[package]]
-name = "codespell"
-version = "2.4.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/15/e0/709453393c0ea77d007d907dd436b3ee262e28b30995ea1aa36c6ffbccaf/codespell-2.4.1.tar.gz", hash = "sha256:299fcdcb09d23e81e35a671bbe746d5ad7e8385972e65dbb833a2eaac33c01e5", size = 344740, upload-time = "2025-01-28T18:52:39.411Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/20/01/b394922252051e97aab231d416c86da3d8a6d781eeadcdca1082867de64e/codespell-2.4.1-py3-none-any.whl", hash = "sha256:3dadafa67df7e4a3dbf51e0d7315061b80d265f9552ebd699b3dd6834b47e425", size = 344501, upload-time = "2025-01-28T18:52:37.057Z" },
+ { name = "ssort", specifier = "==0.16.0" },
]
[[package]]
@@ -58,15 +56,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
-[[package]]
-name = "funlog"
-version = "0.2.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/22/46/4ab8e6d87ab6aae6f1eafdd2f891c009e95e295bec2df8cd0b2f06870417/funlog-0.2.1.tar.gz", hash = "sha256:16f89e9c499077b374b4986a7c7dfc9fcd21ef40eb757fefff1a3631fdddf16f", size = 25691, upload-time = "2025-03-28T18:55:45.341Z" }
-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 = "iniconfig"
version = "2.3.0"
@@ -77,42 +66,30 @@ wheels = [
]
[[package]]
-name = "markdown-it-py"
-version = "4.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" }
-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" },
-]
-
-[[package]]
-name = "mdurl"
+name = "laddercodec"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ff/eb/de8572878a1a11210a61e80568c8c3c1104395c83a02f9b1c0055f03a2ec/laddercodec-0.1.2.tar.gz", hash = "sha256:ee7367603930a2b015a2048c8bf3118ffa72a5e9e469effee1936a9acffd79f4", size = 354866, upload-time = "2026-04-02T14:38:25.223Z" }
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" },
+ { url = "https://files.pythonhosted.org/packages/c5/92/4fb95202cc3e1af89688a4cd84c5ec339026ec6eb659dbe3e3526e94a535/laddercodec-0.1.2-py3-none-any.whl", hash = "sha256:cf028a9140d4b633d70f514f079b1afc41af49575d1f324ad2eb5018694c3b29", size = 122974, upload-time = "2026-04-02T14:38:23.768Z" },
]
[[package]]
name = "packaging"
-version = "25.0"
+version = "26.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
+ { 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 = "pathspec"
-version = "1.0.3"
+version = "1.0.4"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/4c/b2/bb8e495d5262bfec41ab5cb18f522f1012933347fb5d9e62452d446baca2/pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", size = 130841, upload-time = "2026-01-09T15:46:46.009Z" }
+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/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" },
+ { 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]]
@@ -126,14 +103,14 @@ wheels = [
[[package]]
name = "pyclickplc"
-version = "0.1.0"
+version = "0.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pymodbus" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b5/ed/003596602158fbc859a03d7b710c9f9be3d35e3a5cadfd0a9906f08c1285/pyclickplc-0.1.0.tar.gz", hash = "sha256:e8832a81b92c829e5d0691c9516720af72cef7d8bdd794f785b16a034e4c118d", size = 122353, upload-time = "2026-02-27T14:23:49.066Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/04/da/61820cbb5cd9c6f94e701da89ca326828c386a1ff13615f2e1655c96c596/pyclickplc-0.3.1.tar.gz", hash = "sha256:75d06afba3f052f454038e7755f81ac3c49d47bed02d76a2d59b83994b7482a8", size = 137989, upload-time = "2026-03-29T19:29:23.026Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/06/34/92814d0c31dcbfa9e3861625cfd0b248249f5ac6d47c2030f09eeea226e8/pyclickplc-0.1.0-py3-none-any.whl", hash = "sha256:148d20d14b0a512cf610440b21ff49215c2d70471ddcf660b93e5d9407d0d6da", size = 49203, upload-time = "2026-02-27T14:23:47.605Z" },
+ { url = "https://files.pythonhosted.org/packages/06/72/97751ff252abdb637d1f1462bcd010dc422171d7cb5d4caef3488379b4fb/pyclickplc-0.3.1-py3-none-any.whl", hash = "sha256:b061a1167475dd956baf51541ce36c541587220268536e471bea323ff7efa068", size = 52592, upload-time = "2026-03-29T19:29:21.852Z" },
]
[[package]]
@@ -147,11 +124,11 @@ wheels = [
[[package]]
name = "pymodbus"
-version = "3.11.4"
+version = "3.12.1"
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" }
+sdist = { url = "https://files.pythonhosted.org/packages/c8/cf/9ace571421e4ed8bc870e79caca7fe04b7e2529764494f1fb406053640ef/pymodbus-3.12.1.tar.gz", hash = "sha256:bcb483381747b3e3aec2c8e01d5df885e5ea43f85b7145dc1907af06aca93a2c", size = 167673, upload-time = "2026-02-19T21:02:57.035Z" }
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" },
+ { url = "https://files.pythonhosted.org/packages/a5/05/fd7f09ab026be0c5d8433661bbf2181e2802883baadce51955a936286e51/pymodbus-3.12.1-py3-none-any.whl", hash = "sha256:cb4785bdee8870ee593ea1f338f859f50581e3a615261ffbd464602e21d03b85", size = 169517, upload-time = "2026-02-19T21:02:55.009Z" },
]
[[package]]
@@ -207,6 +184,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/8f/d8889efd96bbe8e5d43ff9701f6b1565a8e09c3e1f58c388d550724f777b/pyodbc-5.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:13656184faa3f2d5c6f19b701b8f247342ed581484f58bf39af7315c054e69db", size = 70142, upload-time = "2025-10-17T18:03:55.551Z" },
]
+[[package]]
+name = "pyrsistent"
+version = "0.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ce/3a/5031723c09068e9c8c2f0bc25c3a9245f2b1d1aea8396c787a408f2b95ca/pyrsistent-0.20.0.tar.gz", hash = "sha256:4c48f78f62ab596c679086084d0dd13254ae4f3d6c72a83ffdf5ebdef8f265a4", size = 103642, upload-time = "2023-10-25T21:06:56.342Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/63/7544dc7d0953294882a5c587fb1b10a26e0c23d9b92281a14c2514bac1f7/pyrsistent-0.20.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0f3b1bcaa1f0629c978b355a7c37acd58907390149b7311b5db1b37648eb6958", size = 83481, upload-time = "2023-10-25T21:06:15.238Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/a0/49249bc14d71b1bf2ffe89703acfa86f2017c25cfdabcaea532b8c8a5810/pyrsistent-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cdd7ef1ea7a491ae70d826b6cc64868de09a1d5ff9ef8d574250d0940e275b8", size = 120222, upload-time = "2023-10-25T21:06:17.144Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/94/9808e8c9271424120289b9028a657da336ad7e43da0647f62e4f6011d19b/pyrsistent-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cae40a9e3ce178415040a0383f00e8d68b569e97f31928a3a8ad37e3fde6df6a", size = 120002, upload-time = "2023-10-25T21:06:18.727Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/f6/9ecfb78b2fc8e2540546db0fe19df1fae0f56664a5958c21ff8861b0f8da/pyrsistent-0.20.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6288b3fa6622ad8a91e6eb759cfc48ff3089e7c17fb1d4c59a919769314af224", size = 116850, upload-time = "2023-10-25T21:06:20.424Z" },
+ { url = "https://files.pythonhosted.org/packages/83/c8/e6d28bc27a0719f8eaae660357df9757d6e9ca9be2691595721de9e8adfc/pyrsistent-0.20.0-cp311-cp311-win32.whl", hash = "sha256:7d29c23bdf6e5438c755b941cef867ec2a4a172ceb9f50553b6ed70d50dfd656", size = 60775, upload-time = "2023-10-25T21:06:21.815Z" },
+ { url = "https://files.pythonhosted.org/packages/98/87/c6ef52ff30388f357922d08de012abdd3dc61e09311d88967bdae23ab657/pyrsistent-0.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:59a89bccd615551391f3237e00006a26bcf98a4d18623a19909a2c48b8e986ee", size = 63306, upload-time = "2023-10-25T21:06:22.874Z" },
+ { url = "https://files.pythonhosted.org/packages/15/ee/ff2ed52032ac1ce2e7ba19e79bd5b05d152ebfb77956cf08fcd6e8d760ea/pyrsistent-0.20.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:09848306523a3aba463c4b49493a760e7a6ca52e4826aa100ee99d8d39b7ad1e", size = 83537, upload-time = "2023-10-25T21:06:24.17Z" },
+ { url = "https://files.pythonhosted.org/packages/80/f1/338d0050b24c3132bcfc79b68c3a5f54bce3d213ecef74d37e988b971d8a/pyrsistent-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a14798c3005ec892bbada26485c2eea3b54109cb2533713e355c806891f63c5e", size = 122615, upload-time = "2023-10-25T21:06:25.815Z" },
+ { url = "https://files.pythonhosted.org/packages/07/3a/e56d6431b713518094fae6ff833a04a6f49ad0fbe25fb7c0dc7408e19d20/pyrsistent-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b14decb628fac50db5e02ee5a35a9c0772d20277824cfe845c8a8b717c15daa3", size = 122335, upload-time = "2023-10-25T21:06:28.631Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/bb/5f40a4d5e985a43b43f607250e766cdec28904682c3505eb0bd343a4b7db/pyrsistent-0.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e2c116cc804d9b09ce9814d17df5edf1df0c624aba3b43bc1ad90411487036d", size = 118510, upload-time = "2023-10-25T21:06:30.718Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/13/e6a22f40f5800af116c02c28e29f15c06aa41cb2036f6a64ab124647f28b/pyrsistent-0.20.0-cp312-cp312-win32.whl", hash = "sha256:e78d0c7c1e99a4a45c99143900ea0546025e41bb59ebc10182e947cf1ece9174", size = 60865, upload-time = "2023-10-25T21:06:32.742Z" },
+ { url = "https://files.pythonhosted.org/packages/75/ef/2fa3b55023ec07c22682c957808f9a41836da4cd006b5f55ec76bf0fbfa6/pyrsistent-0.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:4021a7f963d88ccd15b523787d18ed5e5269ce57aa4037146a2377ff607ae87d", size = 63239, upload-time = "2023-10-25T21:06:34.035Z" },
+ { url = "https://files.pythonhosted.org/packages/23/88/0acd180010aaed4987c85700b7cc17f9505f3edb4e5873e4dc67f613e338/pyrsistent-0.20.0-py3-none-any.whl", hash = "sha256:c55acc4733aad6560a7f5f818466631f07efc001fd023f34a6c203f8b6df0f0b", size = 58106, upload-time = "2023-10-25T21:06:54.387Z" },
+]
+
+[[package]]
+name = "pyrung"
+version = "0.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyclickplc" },
+ { name = "pyrsistent" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e9/51/132855d0438903c481ec457297545a383d332fe558f74e35584484b53211/pyrung-0.2.0.tar.gz", hash = "sha256:8192e8e24e964fb90f2cc8f4670bade067de92a51d1ea8f2ab81a4833d3db23b", size = 983731, upload-time = "2026-04-02T14:48:29.099Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f8/07/63a2336969fa0dc2f310adcce44ce23b8413ff8540d2989e36f547beeb4f/pyrung-0.2.0-py3-none-any.whl", hash = "sha256:5b5ef29163178753a6a37ffc2afc2e450816cfb1a6c128ff5eb5dfe1ac2e062e", size = 327131, upload-time = "2026-04-02T14:48:27.391Z" },
+]
+
[[package]]
name = "pytest"
version = "9.0.2"
@@ -242,43 +253,29 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
]
-[[package]]
-name = "rich"
-version = "14.2.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "markdown-it-py" },
- { name = "pygments" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
-]
-
[[package]]
name = "ruff"
-version = "0.14.13"
+version = "0.15.7"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/50/0a/1914efb7903174b381ee2ffeebb4253e729de57f114e63595114c8ca451f/ruff-0.14.13.tar.gz", hash = "sha256:83cd6c0763190784b99650a20fec7633c59f6ebe41c5cc9d45ee42749563ad47", size = 6059504, upload-time = "2026-01-15T20:15:16.918Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c3/ae/0deefbc65ca74b0ab1fd3917f94dc3b398233346a74b8bbb0a916a1a6bf6/ruff-0.14.13-py3-none-linux_armv6l.whl", hash = "sha256:76f62c62cd37c276cb03a275b198c7c15bd1d60c989f944db08a8c1c2dbec18b", size = 13062418, upload-time = "2026-01-15T20:14:50.779Z" },
- { url = "https://files.pythonhosted.org/packages/47/df/5916604faa530a97a3c154c62a81cb6b735c0cb05d1e26d5ad0f0c8ac48a/ruff-0.14.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914a8023ece0528d5cc33f5a684f5f38199bbb566a04815c2c211d8f40b5d0ed", size = 13442344, upload-time = "2026-01-15T20:15:07.94Z" },
- { url = "https://files.pythonhosted.org/packages/4c/f3/e0e694dd69163c3a1671e102aa574a50357536f18a33375050334d5cd517/ruff-0.14.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d24899478c35ebfa730597a4a775d430ad0d5631b8647a3ab368c29b7e7bd063", size = 12354720, upload-time = "2026-01-15T20:15:09.854Z" },
- { url = "https://files.pythonhosted.org/packages/c3/e8/67f5fcbbaee25e8fc3b56cc33e9892eca7ffe09f773c8e5907757a7e3bdb/ruff-0.14.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9aaf3870f14d925bbaf18b8a2347ee0ae7d95a2e490e4d4aea6813ed15ebc80e", size = 12774493, upload-time = "2026-01-15T20:15:20.908Z" },
- { url = "https://files.pythonhosted.org/packages/6b/ce/d2e9cb510870b52a9565d885c0d7668cc050e30fa2c8ac3fb1fda15c083d/ruff-0.14.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac5b7f63dd3b27cc811850f5ffd8fff845b00ad70e60b043aabf8d6ecc304e09", size = 12815174, upload-time = "2026-01-15T20:15:05.74Z" },
- { url = "https://files.pythonhosted.org/packages/88/00/c38e5da58beebcf4fa32d0ddd993b63dfacefd02ab7922614231330845bf/ruff-0.14.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78d2b1097750d90ba82ce4ba676e85230a0ed694178ca5e61aa9b459970b3eb9", size = 13680909, upload-time = "2026-01-15T20:15:14.537Z" },
- { url = "https://files.pythonhosted.org/packages/61/61/cd37c9dd5bd0a3099ba79b2a5899ad417d8f3b04038810b0501a80814fd7/ruff-0.14.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d0bf87705acbbcb8d4c24b2d77fbb73d40210a95c3903b443cd9e30824a5032", size = 15144215, upload-time = "2026-01-15T20:15:22.886Z" },
- { url = "https://files.pythonhosted.org/packages/56/8a/85502d7edbf98c2df7b8876f316c0157359165e16cdf98507c65c8d07d3d/ruff-0.14.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3eb5da8e2c9e9f13431032fdcbe7681de9ceda5835efee3269417c13f1fed5c", size = 14706067, upload-time = "2026-01-15T20:14:48.271Z" },
- { url = "https://files.pythonhosted.org/packages/7e/2f/de0df127feb2ee8c1e54354dc1179b4a23798f0866019528c938ba439aca/ruff-0.14.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:642442b42957093811cd8d2140dfadd19c7417030a7a68cf8d51fcdd5f217427", size = 14133916, upload-time = "2026-01-15T20:14:57.357Z" },
- { url = "https://files.pythonhosted.org/packages/0d/77/9b99686bb9fe07a757c82f6f95e555c7a47801a9305576a9c67e0a31d280/ruff-0.14.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4acdf009f32b46f6e8864af19cbf6841eaaed8638e65c8dac845aea0d703c841", size = 13859207, upload-time = "2026-01-15T20:14:55.111Z" },
- { url = "https://files.pythonhosted.org/packages/7d/46/2bdcb34a87a179a4d23022d818c1c236cb40e477faf0d7c9afb6813e5876/ruff-0.14.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:591a7f68860ea4e003917d19b5c4f5ac39ff558f162dc753a2c5de897fd5502c", size = 14043686, upload-time = "2026-01-15T20:14:52.841Z" },
- { url = "https://files.pythonhosted.org/packages/1a/a9/5c6a4f56a0512c691cf143371bcf60505ed0f0860f24a85da8bd123b2bf1/ruff-0.14.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:774c77e841cc6e046fc3e91623ce0903d1cd07e3a36b1a9fe79b81dab3de506b", size = 12663837, upload-time = "2026-01-15T20:15:18.921Z" },
- { url = "https://files.pythonhosted.org/packages/fe/bb/b920016ece7651fa7fcd335d9d199306665486694d4361547ccb19394c44/ruff-0.14.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:61f4e40077a1248436772bb6512db5fc4457fe4c49e7a94ea7c5088655dd21ae", size = 12805867, upload-time = "2026-01-15T20:14:59.272Z" },
- { url = "https://files.pythonhosted.org/packages/7d/b3/0bd909851e5696cd21e32a8fc25727e5f58f1934b3596975503e6e85415c/ruff-0.14.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6d02f1428357fae9e98ac7aa94b7e966fd24151088510d32cf6f902d6c09235e", size = 13208528, upload-time = "2026-01-15T20:15:03.732Z" },
- { url = "https://files.pythonhosted.org/packages/3b/3b/e2d94cb613f6bbd5155a75cbe072813756363eba46a3f2177a1fcd0cd670/ruff-0.14.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e399341472ce15237be0c0ae5fbceca4b04cd9bebab1a2b2c979e015455d8f0c", size = 13929242, upload-time = "2026-01-15T20:15:11.918Z" },
- { url = "https://files.pythonhosted.org/packages/6a/c5/abd840d4132fd51a12f594934af5eba1d5d27298a6f5b5d6c3be45301caf/ruff-0.14.13-py3-none-win32.whl", hash = "sha256:ef720f529aec113968b45dfdb838ac8934e519711da53a0456038a0efecbd680", size = 12919024, upload-time = "2026-01-15T20:14:43.647Z" },
- { url = "https://files.pythonhosted.org/packages/c2/55/6384b0b8ce731b6e2ade2b5449bf07c0e4c31e8a2e68ea65b3bafadcecc5/ruff-0.14.13-py3-none-win_amd64.whl", hash = "sha256:6070bd026e409734b9257e03e3ef18c6e1a216f0435c6751d7a8ec69cb59abef", size = 14097887, upload-time = "2026-01-15T20:15:01.48Z" },
- { url = "https://files.pythonhosted.org/packages/4d/e1/7348090988095e4e39560cfc2f7555b1b2a7357deba19167b600fdf5215d/ruff-0.14.13-py3-none-win_arm64.whl", hash = "sha256:7ab819e14f1ad9fe39f246cfcc435880ef7a9390d81a2b6ac7e01039083dd247", size = 13080224, upload-time = "2026-01-15T20:14:45.853Z" },
+ { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" },
+ { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" },
+ { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" },
+ { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" },
+ { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" },
]
[[package]]