From b474579bca12b3bdc7811bc308d6499ef7bd6cf3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 10 Oct 2026 17:33:04 -0400 Subject: [PATCH 1/3] 0.4.0: background process launch policy moves to zmqruntime pyqt_reactive.process_launch is deleted; the policy is runtime machinery and lives in zmqruntime 0.6 (zmqruntime.process_launch). The log highlighter and system metrics sampler import it from there. Requires zmqruntime>=0.6,<0.7; CI validates against the zmqruntime 0.6.0 source candidate until it is published. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 ++ pyproject.toml | 2 +- requirements-ci.txt | 1 + src/pyqt_reactive/__init__.py | 2 +- src/pyqt_reactive/process_launch.py | 98 ------------------ .../services/system_metrics_sampler.py | 3 +- .../utils/log_highlight_client.py | 2 +- ...y => test_background_process_consumers.py} | 99 +------------------ tests/test_release_dependency_declarations.py | 15 ++- 9 files changed, 26 insertions(+), 205 deletions(-) delete mode 100644 src/pyqt_reactive/process_launch.py rename tests/{test_process_launch.py => test_background_process_consumers.py} (50%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21f0d43..d9ea8cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.4.0 + +- Remove `pyqt_reactive.process_launch`. The background process launch policy + is runtime machinery and now lives in zmqruntime 0.6 + (`zmqruntime.process_launch`); import it from there. Host families are + subclasses of `BackgroundProcessPlatform` (`WindowsBackgroundProcesses`, + `SessionBackgroundProcesses`) instead of enum members. +- Require zmqruntime 0.6 (`zmqruntime>=0.6,<0.7`). + ## 0.3.30 - A flash registration waiting for its widget to reach a flash window is diff --git a/pyproject.toml b/pyproject.toml index a08a45b..a03c060 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dependencies = [ "Pygments>=2.15", "pyzmq>=22.0", "python-introspect>=0.2.5", - "zmqruntime>=0.3.0,<0.6", + "zmqruntime>=0.6,<0.7", ] [project.optional-dependencies] diff --git a/requirements-ci.txt b/requirements-ci.txt index 60bef0b..0031225 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -1,2 +1,3 @@ # Source candidates needed to validate this checkout before coordinated publication. objectstate @ git+https://github.com/OpenHCSDev/ObjectState.git@44379b0c75fb5bb69c27b4c6a3f4ca6eb28712ca +zmqruntime @ git+https://github.com/OpenHCSDev/ZMQRuntime.git@25501cb3958522ea88f9d55c665e37728a3c1dad diff --git a/src/pyqt_reactive/__init__.py b/src/pyqt_reactive/__init__.py index 9872f71..eea5000 100644 --- a/src/pyqt_reactive/__init__.py +++ b/src/pyqt_reactive/__init__.py @@ -19,7 +19,7 @@ - Cross-window reactive updates """ -__version__ = "0.3.30" +__version__ = "0.4.0" # Public API will be populated as modules are added __all__ = [ diff --git a/src/pyqt_reactive/process_launch.py b/src/pyqt_reactive/process_launch.py deleted file mode 100644 index bd87c0d..0000000 --- a/src/pyqt_reactive/process_launch.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Platform policy for noninteractive background subprocesses. - -The module is intentionally standard-library-only. It is safe to import from -early startup paths before either Qt or the rest of pyqt-reactive is loaded. -""" - -from __future__ import annotations - -import subprocess -import sys -from dataclasses import dataclass -from enum import Enum -from pathlib import Path - - -class BackgroundProcessPlatform(Enum): - """Host families with distinct background-process behavior.""" - - WINDOWS = "win32" - OTHER = "other" - - @classmethod - def current(cls) -> BackgroundProcessPlatform: - """Return the current host family.""" - - if sys.platform == cls.WINDOWS.value: - return cls.WINDOWS - return cls.OTHER - - -@dataclass(frozen=True, slots=True) -class BackgroundProcessLaunchSpec: - """Resolved keyword arguments for one subprocess launch.""" - - creationflags: int = 0 - start_new_session: bool = False - - def popen_arguments(self) -> dict[str, bool | int]: - """Return only non-default ``subprocess.Popen`` arguments.""" - - arguments: dict[str, bool | int] = {} - if self.creationflags: - arguments["creationflags"] = self.creationflags - if self.start_new_session: - arguments["start_new_session"] = True - return arguments - - -@dataclass(frozen=True, slots=True) -class BackgroundProcessLaunchPolicy: - """Own console suppression and detached process-group behavior.""" - - platform: BackgroundProcessPlatform - detached: bool = False - - @classmethod - def current( - cls, - *, - detached: bool = False, - ) -> BackgroundProcessLaunchPolicy: - """Construct the policy for the current host.""" - - return cls( - platform=BackgroundProcessPlatform.current(), - detached=detached, - ) - - def resolve(self) -> BackgroundProcessLaunchSpec: - """Resolve the platform policy to a transportable launch spec.""" - - if self.platform is BackgroundProcessPlatform.WINDOWS: - creationflags = subprocess.CREATE_NO_WINDOW - if self.detached: - creationflags |= subprocess.CREATE_NEW_PROCESS_GROUP - return BackgroundProcessLaunchSpec(creationflags=creationflags) - return BackgroundProcessLaunchSpec(start_new_session=self.detached) - - def popen_arguments(self) -> dict[str, bool | int]: - """Resolve directly to ``subprocess.Popen`` keyword arguments.""" - - return self.resolve().popen_arguments() - - def python_executable(self, executable: str) -> str: - """Return the interpreter for a background Python process family. - - Windows creation flags suppress the first child console, but a child - launched through ``python.exe`` can still create visible consoles when - it starts multiprocessing descendants. Using the colocated - ``pythonw.exe`` makes the console-free interpreter identity transitive. - """ - - if self.platform is not BackgroundProcessPlatform.WINDOWS: - return executable - windowed_executable = Path(executable).with_name("pythonw.exe") - if windowed_executable.is_file(): - return str(windowed_executable) - return executable diff --git a/src/pyqt_reactive/services/system_metrics_sampler.py b/src/pyqt_reactive/services/system_metrics_sampler.py index a2ba1f7..c589dae 100644 --- a/src/pyqt_reactive/services/system_metrics_sampler.py +++ b/src/pyqt_reactive/services/system_metrics_sampler.py @@ -16,8 +16,7 @@ import psutil from python_introspect import validate_annotated_dataclass from zmqruntime.config import PositiveFloat - -from pyqt_reactive.process_launch import BackgroundProcessLaunchPolicy +from zmqruntime.process_launch import BackgroundProcessLaunchPolicy try: import GPUtil diff --git a/src/pyqt_reactive/utils/log_highlight_client.py b/src/pyqt_reactive/utils/log_highlight_client.py index fd59a36..c1f8b0b 100644 --- a/src/pyqt_reactive/utils/log_highlight_client.py +++ b/src/pyqt_reactive/utils/log_highlight_client.py @@ -8,7 +8,7 @@ import threading from dataclasses import dataclass -from pyqt_reactive.process_launch import BackgroundProcessLaunchPolicy +from zmqruntime.process_launch import BackgroundProcessLaunchPolicy @dataclass(frozen=True, slots=True) diff --git a/tests/test_process_launch.py b/tests/test_background_process_consumers.py similarity index 50% rename from tests/test_process_launch.py rename to tests/test_background_process_consumers.py index bb78707..85a10d2 100644 --- a/tests/test_process_launch.py +++ b/tests/test_background_process_consumers.py @@ -1,110 +1,13 @@ -"""Contracts for noninteractive background subprocess creation.""" +"""Background helpers launch through zmqruntime's background process policy.""" from __future__ import annotations -import subprocess -from pathlib import Path from types import SimpleNamespace -from pyqt_reactive.process_launch import ( - BackgroundProcessLaunchPolicy, - BackgroundProcessLaunchSpec, - BackgroundProcessPlatform, -) from pyqt_reactive.services import system_metrics_sampler from pyqt_reactive.utils import log_highlight_client -def test_windows_background_process_suppresses_console(monkeypatch) -> None: - no_window = 0x08000000 - new_process_group = 0x00000200 - monkeypatch.setattr(subprocess, "CREATE_NO_WINDOW", no_window, raising=False) - monkeypatch.setattr( - subprocess, - "CREATE_NEW_PROCESS_GROUP", - new_process_group, - raising=False, - ) - - attached = BackgroundProcessLaunchPolicy( - platform=BackgroundProcessPlatform.WINDOWS, - ) - detached = BackgroundProcessLaunchPolicy( - platform=BackgroundProcessPlatform.WINDOWS, - detached=True, - ) - - assert attached.resolve() == BackgroundProcessLaunchSpec( - creationflags=no_window - ) - assert detached.popen_arguments() == { - "creationflags": no_window | new_process_group - } - - -def test_non_windows_background_process_detaches_without_windows_flags() -> None: - attached = BackgroundProcessLaunchPolicy( - platform=BackgroundProcessPlatform.OTHER, - ) - detached = BackgroundProcessLaunchPolicy( - platform=BackgroundProcessPlatform.OTHER, - detached=True, - ) - - assert attached.popen_arguments() == {} - assert detached.resolve() == BackgroundProcessLaunchSpec( - start_new_session=True - ) - - -def test_windows_background_python_uses_windowed_interpreter( - tmp_path: Path, -) -> None: - python_executable = tmp_path / "python.exe" - windowed_executable = tmp_path / "pythonw.exe" - python_executable.touch() - windowed_executable.touch() - - policy = BackgroundProcessLaunchPolicy( - platform=BackgroundProcessPlatform.WINDOWS, - ) - - assert policy.python_executable(str(python_executable)) == str( - windowed_executable - ) - - -def test_background_python_keeps_original_when_windowed_interpreter_is_absent( - tmp_path: Path, -) -> None: - python_executable = tmp_path / "python.exe" - python_executable.touch() - - policy = BackgroundProcessLaunchPolicy( - platform=BackgroundProcessPlatform.WINDOWS, - ) - - assert policy.python_executable(str(python_executable)) == str( - python_executable - ) - - -def test_non_windows_background_python_keeps_requested_interpreter( - tmp_path: Path, -) -> None: - python_executable = tmp_path / "python" - python_executable.touch() - (tmp_path / "pythonw.exe").touch() - - policy = BackgroundProcessLaunchPolicy( - platform=BackgroundProcessPlatform.OTHER, - ) - - assert policy.python_executable(str(python_executable)) == str( - python_executable - ) - - class _ConsumerLaunchPolicy: @classmethod def current(cls, *, detached=False): diff --git a/tests/test_release_dependency_declarations.py b/tests/test_release_dependency_declarations.py index 20a0b17..0df1904 100644 --- a/tests/test_release_dependency_declarations.py +++ b/tests/test_release_dependency_declarations.py @@ -1,5 +1,6 @@ """The original publisher resolves declared public runtime and test inputs.""" +import re from pathlib import Path import pytest @@ -8,17 +9,23 @@ from scripts.verify_release_ready import project_metadata -def test_published_runtime_is_not_overridden_by_a_source_candidate(): +def test_runtime_source_candidate_is_pinned_to_an_exact_commit(): metadata = project_metadata() runtime = next( requirement for requirement in map(Requirement, metadata["project"]["dependencies"]) if requirement.name == "zmqruntime" ) - assert runtime.specifier.contains("0.3.0") + assert runtime.specifier.contains("0.6.0") constraints = Path("requirements-ci.txt").read_text().splitlines() - assert not any( - Requirement(value).name == runtime.name + candidates = [ + Requirement(value) for value in constraints if value.strip() and not value.lstrip().startswith("#") + ] + # A source candidate is allowed only before coordinated publication and + # only at an exact commit, so it can never drift from the declared bound. + assert all( + re.search(r"@[0-9a-f]{40}$", candidate.url or "") + for candidate in candidates if candidate.name == runtime.name ) From 3a821d7c79edd2c58ee86fd28f3070abbd0a441a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 10 Oct 2026 18:37:38 -0400 Subject: [PATCH 2/3] Release against zmqruntime 0.6.0 from PyPI; restore the no-git-pin release check Co-Authored-By: Claude Opus 5.5 --- requirements-ci.txt | 1 - tests/test_release_dependency_declarations.py | 15 ++++----------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/requirements-ci.txt b/requirements-ci.txt index 0031225..60bef0b 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -1,3 +1,2 @@ # Source candidates needed to validate this checkout before coordinated publication. objectstate @ git+https://github.com/OpenHCSDev/ObjectState.git@44379b0c75fb5bb69c27b4c6a3f4ca6eb28712ca -zmqruntime @ git+https://github.com/OpenHCSDev/ZMQRuntime.git@25501cb3958522ea88f9d55c665e37728a3c1dad diff --git a/tests/test_release_dependency_declarations.py b/tests/test_release_dependency_declarations.py index 0df1904..20a0b17 100644 --- a/tests/test_release_dependency_declarations.py +++ b/tests/test_release_dependency_declarations.py @@ -1,6 +1,5 @@ """The original publisher resolves declared public runtime and test inputs.""" -import re from pathlib import Path import pytest @@ -9,23 +8,17 @@ from scripts.verify_release_ready import project_metadata -def test_runtime_source_candidate_is_pinned_to_an_exact_commit(): +def test_published_runtime_is_not_overridden_by_a_source_candidate(): metadata = project_metadata() runtime = next( requirement for requirement in map(Requirement, metadata["project"]["dependencies"]) if requirement.name == "zmqruntime" ) - assert runtime.specifier.contains("0.6.0") + assert runtime.specifier.contains("0.3.0") constraints = Path("requirements-ci.txt").read_text().splitlines() - candidates = [ - Requirement(value) + assert not any( + Requirement(value).name == runtime.name for value in constraints if value.strip() and not value.lstrip().startswith("#") - ] - # A source candidate is allowed only before coordinated publication and - # only at an exact commit, so it can never drift from the declared bound. - assert all( - re.search(r"@[0-9a-f]{40}$", candidate.url or "") - for candidate in candidates if candidate.name == runtime.name ) From 6898345d03fa647e49171ea66eb421c2ab838a54 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 10 Oct 2026 18:38:26 -0400 Subject: [PATCH 3/3] The release check reads the zmqruntime version the declared range must admit from the installed runtime Co-Authored-By: Claude Opus 5.5 --- tests/test_release_dependency_declarations.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_release_dependency_declarations.py b/tests/test_release_dependency_declarations.py index 20a0b17..6e24bf9 100644 --- a/tests/test_release_dependency_declarations.py +++ b/tests/test_release_dependency_declarations.py @@ -1,5 +1,6 @@ """The original publisher resolves declared public runtime and test inputs.""" +from importlib.metadata import version from pathlib import Path import pytest @@ -14,7 +15,7 @@ def test_published_runtime_is_not_overridden_by_a_source_candidate(): requirement for requirement in map(Requirement, metadata["project"]["dependencies"]) if requirement.name == "zmqruntime" ) - assert runtime.specifier.contains("0.3.0") + assert runtime.specifier.contains(version(runtime.name)) constraints = Path("requirements-ci.txt").read_text().splitlines() assert not any( Requirement(value).name == runtime.name