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]]