diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d4cdd3..3c52673 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 0.1.5 + +Requires `python-introspect>=0.3.0,<0.4` (was `<0.3`), `objectstate>=4.0.1,<5` +and `zmqruntime>=0.6.5,<0.7`. + +Wire and stored form: unchanged. python-introspect 0.3 stores a field whose +declared type admits several classes (a union of dataclasses, an abstract +dataclass, a registered family) as `{tag: fields}`. No bridge request, result, +envelope or descriptor file has such a field, so a 0.1.4 and a 0.1.5 bridge +peer exchange the same JSON and descriptor files written by 0.1.4 still read. +Fields declared `type[Family]` (`field_filter`, `status`) keep the registry +key spelling. + +A field declared as one concrete class carries that class only; a subclass +with more fields is refused when decoded (as before). The identity, address +and connection fields of the bridge results are of this kind +(`UiActionIdentity`, `UiCodeDocumentIdentity`, `UiStateSurfaceIdentity`, +`UiWindowIdentity`, `UiSemanticAddress`, `EndpointConnectionSpec`); the +requests that inherit from them are narrowed before they are stored there. + +Fixed: + +- A refused, busy or accepted `mutate_object_state_field` result put the whole + request in its `address` field, so the client could not decode it + (`UiSemanticAddress received undeclared field(s)`) and the caller saw a + decoding error instead of the refusal. The result now carries + `request.as_address()`. `UiSemanticAddress.as_address()` is new. + ## 0.1.4 Requires ObjectState 4 (`objectstate>=4.0.0,<5`). No code change: formbridge diff --git a/pyproject.toml b/pyproject.toml index 4d35d0b..a528d0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,11 +26,11 @@ keywords = ["forms", "reactive", "dataclass", "ui-bridge", "headless"] dependencies = [ "docutils>=0.20", "metaclass-registry>=0.4.0", - "objectstate>=4.0.0,<5", + "objectstate>=4.0.1,<5", "psutil>=5.9", - "python-introspect>=0.2.7,<0.3", + "python-introspect>=0.3.0,<0.4", "pyzmq>=22.0", - "zmqruntime>=0.6.4,<0.7", + "zmqruntime>=0.6.5,<0.7", ] [project.optional-dependencies] diff --git a/requirements-ci.txt b/requirements-ci.txt index 334b803..f06a292 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -1,2 +1,2 @@ # Source candidates needed to validate this checkout before coordinated publication. -# Empty: every first-party dependency is released (objectstate 4.0.0, zmqruntime 0.6.4, metaclass-registry 0.4.0). +# Empty: every first-party dependency is released (objectstate 4.0.1, zmqruntime 0.6.5, metaclass-registry 0.4.0, python-introspect 0.3.0). diff --git a/src/formbridge/__init__.py b/src/formbridge/__init__.py index ba8236c..2677349 100644 --- a/src/formbridge/__init__.py +++ b/src/formbridge/__init__.py @@ -4,4 +4,4 @@ nothing and loads no GUI toolkit. """ -__version__ = "0.1.4" +__version__ = "0.1.5" diff --git a/src/formbridge/bridge/dto.py b/src/formbridge/bridge/dto.py index 3a7dda1..44a4949 100644 --- a/src/formbridge/bridge/dto.py +++ b/src/formbridge/bridge/dto.py @@ -196,6 +196,11 @@ class UiSemanticAddress: field_path: str window_id: str | None = None + def as_address(self) -> UiSemanticAddress: + """The address alone, without the fields of a request that carries it.""" + + return project_dataclass(UiSemanticAddress, self) + @dataclass(frozen=True, kw_only=True) class UiActionIdentity(UiWidgetIdentity): diff --git a/src/formbridge/bridge/in_process.py b/src/formbridge/bridge/in_process.py index 9147def..8cc8c43 100644 --- a/src/formbridge/bridge/in_process.py +++ b/src/formbridge/bridge/in_process.py @@ -985,7 +985,7 @@ def run(operation: UiBridgeOperationRef) -> UiObjectStateFieldMutationResult: except UiBridgeBusyError as exc: return UiObjectStateFieldMutationResult( schema_version=SCHEMA_VERSION, - address=request, + address=request.as_address(), mutated=False, reset=request.reset, receipt=UiMutationReceipt.rejected_for(request.request_token), diff --git a/src/formbridge/bridge/service.py b/src/formbridge/bridge/service.py index 7fecd88..a459220 100644 --- a/src/formbridge/bridge/service.py +++ b/src/formbridge/bridge/service.py @@ -515,7 +515,7 @@ class UiBridgeMutateObjectStateFieldOperation(UiBridgeOperation): def failed(cls, request, errors): return UiObjectStateFieldMutationResult( schema_version=SCHEMA_VERSION, - address=request, + address=request.as_address(), mutated=False, reset=request.reset, receipt=UiMutationReceipt.rejected_for(request.request_token), @@ -526,7 +526,7 @@ def failed(cls, request, errors): def accepted(cls, request, bridge_operation): return UiObjectStateFieldMutationResult( schema_version=SCHEMA_VERSION, - address=request, + address=request.as_address(), mutated=False, reset=request.reset, receipt=UiMutationReceipt.accepted_for( diff --git a/tests/test_declared_field_classes.py b/tests/test_declared_field_classes.py new file mode 100644 index 0000000..ab48ca9 --- /dev/null +++ b/tests/test_declared_field_classes.py @@ -0,0 +1,85 @@ +"""A bridge field declared as one class carries that class, not a subclass of it.""" + +from __future__ import annotations + +import pytest +from python_introspect import dataclass_from_mapping, to_jsonable + +from formbridge.bridge.dto import ( + UiActionInvokeRequest, + UiActionSummary, + UiBridgeConnectionSpec, + UiBridgeEndpointIdentity, + UiCodeDocumentRequest, + UiCodeDocumentSummary, + UiMutationReceipt, + UiObjectStateFieldMutationRequest, + UiObjectStateFieldMutationResult, + UiSemanticAddress, + UiStateSurfaceRequest, + UiStateSurfaceSummary, + UiWidgetTreeRequest, + UiWindowSummary, +) +from formbridge.bridge.service import UiBridgeMutateObjectStateFieldOperation +from formbridge.contracts import SCHEMA_VERSION + +_MUTATION = UiObjectStateFieldMutationRequest.from_fields( + object_state_scope_id="scope", field_path="field", value=1 +) + + +@pytest.mark.parametrize( + "carrier", + ( + UiObjectStateFieldMutationResult( + schema_version=SCHEMA_VERSION, + address=_MUTATION, + mutated=False, + reset=False, + receipt=UiMutationReceipt.rejected_for(_MUTATION.request_token), + ), + UiBridgeEndpointIdentity(connection=UiBridgeConnectionSpec(port=1, auth_token="token")), + UiCodeDocumentSummary( + widget_id="widget", + schema_version=SCHEMA_VERSION, + identity=UiCodeDocumentRequest(document_id="document"), + title="title", + readable=True, + writable=False, + ), + UiStateSurfaceSummary( + widget_id="widget", + schema_version=SCHEMA_VERSION, + identity=UiStateSurfaceRequest.from_fields(surface_id="surface"), + title="title", + readable=True, + ), + UiActionSummary( + schema_version=SCHEMA_VERSION, + identity=UiActionInvokeRequest(widget_id="widget", action_id="action"), + title="title", + enabled=True, + invocation_mode="immediate", + ), + UiWindowSummary( + schema_version=SCHEMA_VERSION, + identity=UiWidgetTreeRequest(window_id="window", open_policy="existing"), + title="title", + window_kind="kind", + visible=True, + focusable=True, + ), + ), + ids=lambda carrier: type(carrier).__name__, +) +def test_subclass_in_a_field_declared_as_one_class_is_refused(carrier: object) -> None: + with pytest.raises(ValueError, match="undeclared field"): + dataclass_from_mapping(type(carrier), to_jsonable(carrier)) + + +def test_refused_mutation_result_carries_the_address_alone() -> None: + result = UiBridgeMutateObjectStateFieldOperation.failed(_MUTATION, ()) + + assert type(result.address) is UiSemanticAddress + assert dataclass_from_mapping(type(result), to_jsonable(result)) == result