From ef85882f5f6c0a2269524b7ad457367fffbe361f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 13:13:17 -0400 Subject: [PATCH] 0.8.0: the config window keeps no global config beside the global state ObjectState 4 makes the state registered at the global scope the one holder of the global config, so everything the window did to keep the stores in step with that state is derived and deleted: the live write on a code edit, the capture and restore of the stores beside the state checkpoint, the saved and live writes after mark_saved, the restore of the live store on cancel, and the dirty and saving flags that only guarded them. Requires objectstate>=4.0.0,<5 and formbridge>=0.1.4,<0.2. CHANGELOG.md lists each removed name. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 31 ++++ pyproject.toml | 4 +- requirements-ci.txt | 4 +- src/pyqt_reactive/__init__.py | 2 +- src/pyqt_reactive/windows/config_window.py | 161 ++------------------- tests/test_config_window.py | 108 +++++++++++++- tests/test_provenance_navigation.py | 4 +- 7 files changed, 157 insertions(+), 157 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94c7cf3..00903fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## 0.8.0 + +Requires ObjectState 4 (`objectstate>=4.0.0,<5`) and `formbridge>=0.1.4,<0.2`. + +In ObjectState 4 the state registered at the global scope holds the global +config: an edit of that state is the live global config, `mark_saved` is the +saved one, `restore_saved` and a checkpoint restore put both back. The config +window therefore keeps nothing beside the state. + +Removed from `pyqt_reactive.windows.config_window`, and what to use instead: + +- `GlobalConfigContextCheckpoint`, `ConfigEditSession.capture_global_context`, + `ConfigEditSession.restore_global_context`: nothing; + `ObjectStateTransactionCheckpoint.capture(state)` / `.restore(state)` of the + global state restores the global config. +- `ConfigEditSession.publish_saved_global_config`: nothing; `state.mark_saved()` + is the save. +- `ConfigEditSession.apply_code_edit_context`: nothing; applying a code edit to + the form edits the state. +- `ConfigEditSession.restore_global_context_if_dirty`, + `ConfigEditSession.global_context_dirty`, + `ConfigEditSession.mark_global_field_changed`: nothing; restoring the state + (`restore_saved`) restores the live global config. Ask `state.dirty_fields` + whether there are unsaved edits. +- `ConfigEditSession.saving`, `begin_save_callback`, `end_save_callback`: + nothing; they only guarded `mark_global_field_changed`. +- `ConfigEditSession.is_global_config`: `is_global_config_type(session.config_class)`. + +Behaviour: a config window whose global-config state is not registered at the +global scope no longer changes the global config when it is edited or saved. + ## 0.7.2 Requires `metaclass-registry>=0.4.0`, `objectstate>=3.0.1,<4`, diff --git a/pyproject.toml b/pyproject.toml index d6709b1..a7bb4d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,11 +25,11 @@ keywords = ["pyqt6", "forms", "reactive", "gui", "widgets", "dataclass"] dependencies = [ "annotated-types>=0.7.0", - "formbridge>=0.1.3,<0.2", + "formbridge>=0.1.4,<0.2", "magicgui>=0.7.0", "metaclass-registry>=0.4.0", "PyQt6>=6.4.0", - "objectstate>=3.0.1,<4", + "objectstate>=4.0.0,<5", "psutil>=5.9", "Pygments>=2.15", "python-introspect>=0.2.6,<0.3", diff --git a/requirements-ci.txt b/requirements-ci.txt index 6a1d9ea..902c19c 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -1,3 +1,3 @@ # Source candidates needed to validate this checkout before coordinated publication. -# Empty: every first-party dependency is released (objectstate 3.0.1, -# formbridge 0.1.3, zmqruntime 0.6.4, metaclass-registry 0.4.0). +# Empty: every first-party dependency is released (objectstate 4.0.0, +# formbridge 0.1.4, zmqruntime 0.6.4, metaclass-registry 0.4.0). diff --git a/src/pyqt_reactive/__init__.py b/src/pyqt_reactive/__init__.py index c91af9c..1452823 100644 --- a/src/pyqt_reactive/__init__.py +++ b/src/pyqt_reactive/__init__.py @@ -19,7 +19,7 @@ - Cross-window reactive updates """ -__version__ = "0.7.2" +__version__ = "0.8.0" # Public API will be populated as modules are added __all__ = [ diff --git a/src/pyqt_reactive/windows/config_window.py b/src/pyqt_reactive/windows/config_window.py index a9856fa..21b215a 100644 --- a/src/pyqt_reactive/windows/config_window.py +++ b/src/pyqt_reactive/windows/config_window.py @@ -10,18 +10,21 @@ from functools import partial from typing import ClassVar, Generic, TypeVar, cast +from formbridge.forms.layout_constants import CURRENT_LAYOUT +from formbridge.services.code_editor_form_updater import CodeEditorFormUpdater +from formbridge.services.window_code_document import ( + PYTHON_MIME_TYPE, + WindowCodeDocument, + WindowCodeDocumentDriver, + use_external_code_editor, +) from objectstate import ( DottedFieldPath, ObjectState, ObjectStateEditSession, ObjectStateRegistry, ObjectStateTransactionCheckpoint, - get_live_global_config, - get_saved_global_config, - is_global_config_type, patch_lazy_constructors, - set_live_global_config, - set_saved_global_config, ) from objectstate.lazy_factory import LazyDataclass, retain_only_authored_paths from PyQt6.QtCore import QSize, pyqtSignal @@ -35,19 +38,11 @@ QWidget, ) -from formbridge.forms.layout_constants import CURRENT_LAYOUT from pyqt_reactive.forms.parameter_form_manager import ( FormManagerConfig, ParameterFormManager, ) -from formbridge.services.code_editor_form_updater import CodeEditorFormUpdater from pyqt_reactive.services.parameter_ops_service import ParameterOpsService -from formbridge.services.window_code_document import ( - PYTHON_MIME_TYPE, - WindowCodeDocument, - WindowCodeDocumentDriver, - use_external_code_editor, -) from pyqt_reactive.services.window_navigation import ( RegisteredWindowNavigationRequest, WindowNavigationDriver, @@ -100,31 +95,15 @@ def from_source(self, source: str, *, expected_config_type: type) -> object: """Read the config a source document declares.""" -@dataclass(frozen=True, slots=True) -class GlobalConfigContextCheckpoint(Generic[ConfigT]): - """Exact external and edit-session context at one transaction boundary.""" - - saved: ConfigT | None - live: ConfigT | None - original_live: ConfigT | None - dirty: bool - - @dataclass(slots=True) class ConfigEditSession(Generic[ConfigT]): """Own non-visual config edit state for ConfigWindow.""" state: ObjectState original_config: ConfigT - global_context_dirty: bool = False - saving: bool = False - _original_live_global_config: ConfigT | None = field(init=False, repr=False) _object_session: ObjectStateEditSession[ConfigT] = field(init=False, repr=False) def __post_init__(self) -> None: - self._original_live_global_config = None - if self.is_global_config: - self._original_live_global_config = get_live_global_config(self.config_class) self._object_session = ObjectStateEditSession( state_provider=lambda: self.state, fallback_object=self.original_config, @@ -135,10 +114,6 @@ def __post_init__(self) -> None: def config_class(self) -> type[ConfigT]: return type(self.original_config) - @property - def is_global_config(self) -> bool: - return is_global_config_type(self.config_class) - def to_object(self) -> ConfigT: """Reconstruct the current config object from ObjectState storage.""" return self._object_session.to_object() @@ -161,80 +136,6 @@ def to_code_document_object(self) -> ConfigT: retain_only_authored_paths(config, self.state.signature_diff_fields), ) - def begin_save_callback(self, window_id: int) -> None: - self.saving = True - logger.info("SAVE_CONFIG: Set saving=True before callback (id=%s)", window_id) - - def end_save_callback(self, window_id: int) -> None: - self.saving = False - logger.info("SAVE_CONFIG: Reset saving=False (id=%s)", window_id) - - def mark_global_field_changed(self) -> None: - """Track unsaved global-context changes unless a save is in progress.""" - if self.saving: - return - self.global_context_dirty = True - - def apply_code_edit_context(self, new_config: ConfigT) -> None: - """Publish an unsaved code edit to the live UI context only.""" - if not self.is_global_config: - return - set_live_global_config(self.config_class, new_config) - self.global_context_dirty = True - logger.debug( - "Updated LIVE thread-local %s context", - self.config_class.__name__, - ) - - def capture_global_context(self) -> GlobalConfigContextCheckpoint[ConfigT] | None: - """Capture saved and live stores independently before a fallible save.""" - - if not self.is_global_config: - return None - return GlobalConfigContextCheckpoint( - saved=get_saved_global_config(self.config_class), - live=get_live_global_config(self.config_class), - original_live=self._original_live_global_config, - dirty=self.global_context_dirty, - ) - - def restore_global_context( - self, - checkpoint: GlobalConfigContextCheckpoint[ConfigT] | None, - ) -> None: - """Restore the exact saved/live split captured before a save.""" - - if not self.is_global_config or checkpoint is None: - return - set_saved_global_config(self.config_class, checkpoint.saved) - set_live_global_config(self.config_class, checkpoint.live) - self._original_live_global_config = checkpoint.original_live - self.global_context_dirty = checkpoint.dirty - - def publish_saved_global_config(self, new_config: ConfigT) -> None: - """Publish a saved global config to the saved/live thread-local stores.""" - if not self.is_global_config: - return - set_saved_global_config(self.config_class, new_config) - set_live_global_config(self.config_class, new_config) - logger.debug( - "Updated SAVED and LIVE thread-local %s on SAVE", - self.config_class.__name__, - ) - self._original_live_global_config = new_config - self.global_context_dirty = False - - def restore_global_context_if_dirty(self) -> bool: - """Restore only the live context that preceded this edit session.""" - if not self.is_global_config or not self.global_context_dirty: - return False - set_live_global_config( - self.config_class, - self._original_live_global_config, - ) - self.global_context_dirty = False - return True - class ConfigCodeDocumentDriver(WindowCodeDocumentDriver, Generic[ConfigT]): """Render and apply configuration source through the application's document format.""" @@ -553,12 +454,6 @@ def _materialize_tab( form_config.field_id = "" form_manager = ParameterFormManager(state=state, config=form_config) form_manager.form_build_failed.connect(partial(self._on_form_build_failed, tab)) - if session.is_global_config: - form_manager.parameter_changed.connect( - lambda _name, _value, current_session=session: ( - current_session.mark_global_field_changed() - ) - ) tree_helper = ConfigHierarchyTreeHelper() tree_widget = tree_helper.create_tree_from_root_dataclass( @@ -586,8 +481,7 @@ def _materialize_tab( config_type=config_type, current_config=session.to_code_document_object, apply_config=partial( - self._apply_config_from_code_document, - session, + CodeEditorFormUpdater.update_form_from_instance, form_manager, ), before_read=partial( @@ -809,7 +703,6 @@ def save_config(self, *, close_window: bool = True) -> None: attempted_participants: list[_ConfigEditorTab] = [] committed: tuple[tuple[_ConfigEditorTab, object], ...] = () state_checkpoints: list[tuple[ObjectState, ObjectStateTransactionCheckpoint]] = [] - global_context_checkpoints: dict[int, object] = {} transaction_started = False try: # Widget validation and authorization precede even reconstruction @@ -827,11 +720,6 @@ def save_config(self, *, close_window: bool = True) -> None: (state, ObjectStateTransactionCheckpoint.capture(state)) for state in states ] previous_objects = {id(tab): tab.state.saved_object for tab in self._tabs} - global_context_checkpoints = { - id(tab): checkpoint - for tab in self._tabs - if (checkpoint := tab.session.capture_global_context()) is not None - } transaction_started = True with ObjectStateRegistry.atomic_success( @@ -844,11 +732,10 @@ def save_config(self, *, close_window: bool = True) -> None: tab.state.mark_saved() committed = tuple((tab, tab.state.saved_object) for tab, _ in candidates) - for tab, config in committed: - tab.session.publish_saved_global_config(config) - # External effects run only after every ObjectState and global - # context exposes the committed object. Include the current + # External effects run only after every ObjectState exposes the + # committed object (a saved global state is the saved global + # config). Include the current # participant before invocation so its own partial apply is # compensated if it raises. for tab, config in committed: @@ -856,11 +743,7 @@ def save_config(self, *, close_window: bool = True) -> None: if participant is None: continue attempted_participants.append(tab) - tab.session.begin_save_callback(id(self)) - try: - participant.apply(config) - finally: - tab.session.end_save_callback(id(self)) + participant.apply(config) except Exception as error: rollback_errors: list[Exception] = [] @@ -872,18 +755,10 @@ def save_config(self, *, close_window: bool = True) -> None: participant = tab.spec.save_participant if participant is None: continue - tab.session.begin_save_callback(id(self)) try: participant.rollback(previous_objects[id(tab)]) except Exception as rollback_error: rollback_errors.append(rollback_error) - finally: - tab.session.end_save_callback(id(self)) - for tab in self._tabs: - try: - tab.session.restore_global_context(global_context_checkpoints.get(id(tab))) - except Exception as rollback_error: - rollback_errors.append(rollback_error) for state, checkpoint in state_checkpoints: try: checkpoint.restore(state) @@ -946,15 +821,6 @@ def _view_code( f"Failed to view code:\n{error}", ) - @staticmethod - def _apply_config_from_code_document( - session: ConfigEditSession, - form_manager: ParameterFormManager, - new_config: object, - ) -> None: - session.apply_code_edit_context(new_config) - CodeEditorFormUpdater.update_form_from_instance(form_manager, new_config) - def require_managed_state_mutation_allowed(self) -> None: for tab in self._tabs: if tab.spec.before_mutation is not None: @@ -963,7 +829,6 @@ def require_managed_state_mutation_allowed(self) -> None: def restore_managed_state(self) -> None: for tab in self._tabs: tab.restore_policy.restore(tab.state) - tab.session.restore_global_context_if_dirty() def before_managed_reject(self) -> None: self.config_cancelled.emit() diff --git a/tests/test_config_window.py b/tests/test_config_window.py index 1a8daa1..43ebc22 100644 --- a/tests/test_config_window.py +++ b/tests/test_config_window.py @@ -2,10 +2,19 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field import pytest -from objectstate import ObjectState, ObjectStateRegistry, set_base_config_type +from objectstate import ( + LazyDataclassFactory, + ObjectState, + ObjectStateRegistry, + get_live_global_config, + get_saved_global_config, + mark_global_config_type, + set_base_config_type, +) +from PyQt6.QtWidgets import QMessageBox from pyqt_reactive.windows.config_window import ( ConfigDocumentFormat, @@ -72,3 +81,98 @@ def test_view_code_and_save_go_through_the_declared_format_and_participant(qapp) window.before_managed_close() window.deleteLater() qapp.processEvents() + + +# --- The global config through the window --------------------------------------- +# The state registered at the global scope holds the global config (ObjectState 4), +# so the window keeps nothing beside it: a code edit, a cancel, a save and a failed +# save move the live and the saved global config, and a descendant, with the state. + + +@dataclass(frozen=True) +class Policy: + count: int | None = None + + +@dataclass(frozen=True) +class GlobalSettings: + policy: Policy = field(default_factory=lambda: Policy(count=1)) + + +mark_global_config_type(GlobalSettings) +LazyPolicy = LazyDataclassFactory.make_lazy_simple(Policy) + + +@dataclass +class Plate: + policy: LazyPolicy = field(default_factory=LazyPolicy) + + +class GlobalSettingsFormat(ConfigDocumentFormat): + def render(self, config, *, expected_config_type, clean_mode=True) -> str: + return f"count={config.policy.count}" + + def from_source(self, source: str, *, expected_config_type): + return expected_config_type(policy=Policy(count=int(source.split("=", 1)[1]))) + + +class GlobalSettingsWindow(ConfigWindow): + document_format = GlobalSettingsFormat() + + +def test_global_config_follows_the_window_through_edit_cancel_save_and_failed_save( + qapp, monkeypatch +) -> None: + monkeypatch.setattr(QMessageBox, "critical", lambda *args, **kwargs: None) + set_base_config_type(GlobalSettings) + global_state = ObjectState(GlobalSettings(), scope_id="") + plate = ObjectState(Plate(), scope_id="/plate") + for state in (global_state, plate): + ObjectStateRegistry.register(state, _skip_snapshot=True) + applied: list[int] = [] + + def apply(config: GlobalSettings) -> None: + if config.policy.count == 13: + raise RuntimeError("refused") + applied.append(config.policy.count) + + def seen() -> tuple: + """Live and saved: the global config, then what the plate inherits.""" + return ( + get_live_global_config(GlobalSettings).policy.count, + get_saved_global_config(GlobalSettings).policy.count, + plate.get_resolved_value("policy.count"), + plate.get_saved_resolved_value("policy.count"), + ) + + window = GlobalSettingsWindow( + tabs=( + ConfigWindowTabSpec( + state=global_state, + save_participant=ConfigSaveParticipant(apply=apply, rollback=lambda _c: None), + ), + ), + scope_id="", + ) + try: + qapp.processEvents() + driver = window.window_code_document_driver() + assert seen() == (1, 1, 1, 1) + + driver.apply_source("count=5") # an unsaved code edit + assert seen() == (5, 1, 5, 1) + + window.restore_managed_state() # cancel + assert seen() == (1, 1, 1, 1) + + driver.apply_source("count=13") + window.save_config(close_window=False) # the participant refuses + assert seen() == (13, 1, 13, 1) and applied == [] + + driver.apply_source("count=7") + window.save_config(close_window=False) + assert seen() == (7, 7, 7, 7) and applied == [7] + finally: + window.before_managed_close() + window.deleteLater() + qapp.processEvents() diff --git a/tests/test_provenance_navigation.py b/tests/test_provenance_navigation.py index 8bae04f..f668d21 100644 --- a/tests/test_provenance_navigation.py +++ b/tests/test_provenance_navigation.py @@ -15,7 +15,7 @@ ObjectStateRegistry, mark_global_config_type, set_base_config_type, - set_global_config_for_editing, + set_global_config, ) from pyqt_reactive.services.scope_window_navigation import ScopeWindowNavigationService @@ -57,7 +57,7 @@ def label(qapp, request, monkeypatch): previous_base = config_module._base_config_type ObjectStateRegistry.clear() set_base_config_type(SourceGlobalConfig) - set_global_config_for_editing( + set_global_config( SourceGlobalConfig, SourceGlobalConfig(SourceFilterConfig(well_filter=1)) ) state = ObjectState(SourcePipeline(), scope_id=request.param)