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)