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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`,
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions requirements-ci.txt
Original file line number Diff line number Diff line change
@@ -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).
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.7.2"
__version__ = "0.8.0"

# Public API will be populated as modules are added
__all__ = [
Expand Down
161 changes: 13 additions & 148 deletions src/pyqt_reactive/windows/config_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand All @@ -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."""
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -844,23 +732,18 @@ 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:
participant = tab.spec.save_participant
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] = []
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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()
Expand Down
Loading
Loading