Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 41 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,9 @@ jobs:
pip install -e ".[dev]"

- name: Run tests with coverage
timeout-minutes: 5
run: |
pytest --cov=pyqt_reactive --cov-report=xml --cov-report=html --cov-report=term-missing -v
pytest --cov=pyqt_reactive --cov-report=xml --cov-report=html --cov-report=term-missing -v -o faulthandler_timeout=120

- name: Upload coverage to Codecov
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
Expand All @@ -64,6 +65,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup Python
uses: actions/setup-python@v5
Expand All @@ -73,17 +76,49 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install ruff black mypy
pip install -e ".[dev]"

- name: Run full-package Ruff fatal checks
run: |
ruff check src/ --select E9,F63,F7,F822,F823 --output-format=github

- name: Resolve quality diff base
id: quality-base
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BASE_SHA: ${{ github.event.before }}
run: |
case "$EVENT_NAME" in
pull_request) base_sha="$PR_BASE_SHA" ;;
push) base_sha="$PUSH_BASE_SHA" ;;
*) base_sha="" ;;
esac
if [[ -z "$base_sha" || "$base_sha" =~ ^0+$ ]] ||
! git cat-file -e "${base_sha}^{commit}"; then
base_sha="$(git rev-parse HEAD^)"
fi
echo "sha=$base_sha" >> "$GITHUB_OUTPUT"

- name: Reject Ruff diagnostics introduced on added lines
run: |
python scripts/check_ruff_added_lines.py \
--base "${{ steps.quality-base.outputs.sha }}" \
--head "$GITHUB_SHA"

- name: Run ruff (linting)
- name: Report repository-wide Ruff baseline (non-blocking)
continue-on-error: true
run: |
ruff check src/ --output-format=github
ruff check src/ --statistics

- name: Run black (formatting check)
- name: Report repository-wide Black baseline (non-blocking)
continue-on-error: true
run: |
black --check src/

- name: Run mypy (type checking)
- name: Report repository-wide Mypy baseline (non-blocking)
continue-on-error: true
run: |
mypy src/ --ignore-missing-imports

Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## [0.1.28] - 2026-07-30

### Changed

- Reuse ObjectState's indexed nested-field topology instead of rescanning flat
paths for each form manager.
- Coalesce responsive-row construction into one layout transaction while
preserving reflow when group geometry changes.
- Resolve placeholders from canonical full ObjectState paths.

### Dependencies

- objectstate >= 1.0.21

## [0.1.0] - 2025-01-10

### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Requirements

* Python 3.11+
* PyQt6 >= 6.4.0
* ObjectState >= 1.0.20
* ObjectState >= 1.0.21
* python-introspect >= 0.1.6
* zmqruntime >= 0.1.19

Expand Down
10 changes: 5 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "pyqt-reactive"
version = "0.1.27"
version = "0.1.28"
description = "React-quality reactive form generation framework for PyQt6"
authors = [{name = "Tristan Simas", email = "tristan.simas@mail.mcgill.ca"}]
license = {text = "MIT"}
Expand All @@ -29,7 +29,7 @@ dependencies = [
"magicgui>=0.7.0",
"metaclass-registry>=0.1.5",
"PyQt6>=6.4.0",
"objectstate>=1.0.20",
"objectstate>=1.0.21",
"psutil>=5.9",
"Pygments>=2.15",
"pyzmq>=22.0",
Expand All @@ -42,9 +42,9 @@ dev = [
"pytest>=7.0",
"pytest-qt>=4.0",
"pytest-cov>=4.0",
"black>=23.0",
"ruff>=0.1.0",
"mypy>=1.0",
"black==26.5.1",
"ruff==0.16.0",
"mypy==2.3.0",
]
docs = [
"sphinx>=7.0",
Expand Down
155 changes: 155 additions & 0 deletions scripts/check_ruff_added_lines.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Fail when configured Ruff diagnostics touch lines added by a Git diff."""

from __future__ import annotations

import argparse
import json
import os
import re
import subprocess
import sys
from pathlib import Path

HUNK_HEADER = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")


def _git_output(*arguments: str, binary: bool = False) -> str | bytes:
result = subprocess.run(
("git", *arguments),
check=True,
capture_output=True,
text=not binary,
)
return result.stdout


def _changed_python_files(base: str, head: str | None) -> tuple[Path, ...]:
revision_arguments = (base, head) if head is not None else (base,)
output = _git_output(
"diff",
"--name-only",
"--diff-filter=ACMR",
"-z",
*revision_arguments,
"--",
"*.py",
binary=True,
)
assert isinstance(output, bytes)
return tuple(Path(os.fsdecode(path)) for path in output.split(b"\0") if path)


def _added_lines(
path: Path,
base: str,
head: str | None,
) -> frozenset[int]:
revision_arguments = (base, head) if head is not None else (base,)
output = _git_output(
"diff",
"--unified=0",
"--no-ext-diff",
"--no-color",
*revision_arguments,
"--",
os.fspath(path),
)
assert isinstance(output, str)
added: set[int] = set()
for line in output.splitlines():
match = HUNK_HEADER.match(line)
if match is None:
continue
first_line = int(match.group(1))
line_count = int(match.group(2) or "1")
added.update(range(first_line, first_line + line_count))
return frozenset(added)


def _ruff_diagnostics(paths: tuple[Path, ...]) -> list[dict[str, object]]:
result = subprocess.run(
("ruff", "check", "--output-format=json", *(os.fspath(path) for path in paths)),
check=False,
capture_output=True,
text=True,
)
try:
diagnostics = json.loads(result.stdout)
except json.JSONDecodeError as error:
raise RuntimeError(
"Ruff did not return JSON diagnostics.\n"
f"stdout:\n{result.stdout}\n"
f"stderr:\n{result.stderr}"
) from error
if result.returncode not in {0, 1}:
raise RuntimeError(
f"Ruff failed with exit code {result.returncode}:\n{result.stderr}"
)
return diagnostics


def _github_escape(value: object) -> str:
return (
str(value)
.replace("%", "%25")
.replace("\r", "%0D")
.replace("\n", "%0A")
)


def _relative_diagnostic_path(filename: object) -> Path:
path = Path(str(filename))
if path.is_absolute():
return path.relative_to(Path.cwd())
return path


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base", required=True, help="Git revision before the change")
parser.add_argument(
"--head",
help="Git revision after the change; omit to inspect the current worktree",
)
arguments = parser.parse_args()

changed_paths = _changed_python_files(arguments.base, arguments.head)
if not changed_paths:
print("No changed Python files.")
return 0

line_index = {
path: _added_lines(path, arguments.base, arguments.head)
for path in changed_paths
}
introduced = []
for diagnostic in _ruff_diagnostics(changed_paths):
path = _relative_diagnostic_path(diagnostic["filename"])
location = diagnostic["location"]
assert isinstance(location, dict)
row = int(location["row"])
if row in line_index.get(path, frozenset()):
introduced.append((path, row, location, diagnostic))

if not introduced:
print(
"Configured Ruff rules report no diagnostics on added Python lines "
f"across {len(changed_paths)} changed files."
)
return 0

for path, row, location, diagnostic in introduced:
code = diagnostic["code"]
message = diagnostic["message"]
print(
f"::error file={_github_escape(path)},line={row},"
f"col={location['column']},title=Ruff {code}::"
f"{_github_escape(message)}"
)
print(f"{len(introduced)} Ruff diagnostics touch added Python lines.")
return 1


if __name__ == "__main__":
sys.exit(main())
2 changes: 1 addition & 1 deletion src/pyqt_reactive/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
- Cross-window reactive updates
"""

__version__ = "0.1.27"
__version__ = "0.1.28"

# Public API will be populated as modules are added
__all__ = [
Expand Down
28 changes: 7 additions & 21 deletions src/pyqt_reactive/forms/parameter_form_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,38 +132,24 @@ class ParameterFormTypeResolver:
@staticmethod
def field_type(manager: "ParameterFormManager", field_name: str, signature_type: Type) -> Type:
dotted_path = f"{manager.field_id}.{field_name}" if manager.field_id else field_name
if not ParameterFormTypeResolver.path_has_children(manager.state.parameters, dotted_path):
if not manager.state.has_parameter_descendants(dotted_path):
return signature_type

state_type = manager.state.type_for_path(dotted_path)
if isinstance(state_type, type) and is_dataclass(state_type):
return state_type
return signature_type

@staticmethod
def path_has_children(parameters: Dict[str, Any], dotted_path: str) -> bool:
owner_path = DottedFieldPath(dotted_path)
return any(owner_path.contains_path(path) for path in parameters if path != dotted_path)

@staticmethod
def scoped_parameters(state, field_id: str) -> ParameterDefaultsByName:
"""Project ObjectState's flat parameters into one form manager scope."""
if not field_id:
return ParameterDefaultsByName(
(key, value) for key, value in state.parameters.items() if "." not in key
return ParameterDefaultsByName(
(
parameter_path.field_name,
state.parameters[parameter_path.value],
)

owner_path = DottedFieldPath(field_id)
result = ParameterDefaultsByName()
for path, value in state.parameters.items():
if owner_path.contains_path(path) and path != field_id:
suffix = path[len(field_id) :]
if not suffix.startswith("."):
continue
remainder = suffix[1:]
if "." not in remainder:
result[remainder] = value
return result
for parameter_path in state.direct_parameter_paths(field_id)
)


class ParameterFormManager(
Expand Down
12 changes: 11 additions & 1 deletion src/pyqt_reactive/forms/widget_creation_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,17 @@ def __init__(self, manager: ParameterFormManager, param_info: ParameterInfo) ->
def run(self) -> QWidget:
"""Run the widget creation stages."""
self.create_container()
rt = self.runtime
if isinstance(rt.container, ResponsiveParameterRow):
with rt.container.layout_update():
self._run_content_stages()
else:
self._run_content_stages()
return self.runtime.container

def _run_content_stages(self) -> None:
"""Build one row's content within its owner's layout transaction."""

self.configure_nested_container()
self.setup_layout()
self.add_optional_title()
Expand All @@ -1104,7 +1115,6 @@ def run(self) -> QWidget:
self.connect_optional_checkbox()
self.store_and_connect_widget()
self.initialize_widget_semantics()
return self.runtime.container

def create_container(self) -> None:
rt = self.runtime
Expand Down
5 changes: 2 additions & 3 deletions src/pyqt_reactive/services/parameter_ops_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,10 +354,9 @@ def refresh_all_placeholders(self, manager) -> None:
if not PyQt6WidgetEnhancer.supports_placeholder(widget):
continue

# Use manager.parameters (scoped) not state.parameters (full paths)
current_value = manager.parameters.get(param_name)
should_apply_placeholder = (current_value is None)
full_path = f"{manager.field_id}.{param_name}" if manager.field_id else param_name
current_value = manager.state.parameters.get(full_path)
should_apply_placeholder = current_value is None
resolved_value = manager.state.get_resolved_value(full_path)

if should_apply_placeholder:
Expand Down
Loading
Loading