graphddb-runtime 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/PKG-INFO +1 -1
  2. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/graphddb_runtime/batch.py +2 -1
  3. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/graphddb_runtime/runtime.py +70 -15
  4. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/graphddb_runtime.egg-info/PKG-INFO +1 -1
  5. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/pyproject.toml +1 -1
  6. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/tests/test_integration_command.py +33 -14
  7. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/tests/test_unit.py +29 -20
  8. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/README.md +0 -0
  9. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/graphddb_runtime/__init__.py +0 -0
  10. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/graphddb_runtime/async_runtime.py +0 -0
  11. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/graphddb_runtime/concurrency.py +0 -0
  12. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/graphddb_runtime/cursor.py +0 -0
  13. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/graphddb_runtime/errors.py +0 -0
  14. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/graphddb_runtime/filters.py +0 -0
  15. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/graphddb_runtime/hydration.py +0 -0
  16. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/graphddb_runtime/limits.py +0 -0
  17. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/graphddb_runtime/per_key_cursor.py +0 -0
  18. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/graphddb_runtime/relations.py +0 -0
  19. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/graphddb_runtime/templates.py +0 -0
  20. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/graphddb_runtime/transactions.py +0 -0
  21. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/graphddb_runtime.egg-info/SOURCES.txt +0 -0
  22. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/graphddb_runtime.egg-info/dependency_links.txt +0 -0
  23. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/graphddb_runtime.egg-info/requires.txt +0 -0
  24. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/graphddb_runtime.egg-info/top_level.txt +0 -0
  25. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/setup.cfg +0 -0
  26. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/tests/test_concurrency.py +0 -0
  27. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/tests/test_contract_runtime.py +0 -0
  28. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/tests/test_integration.py +0 -0
  29. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/tests/test_integration_compose.py +0 -0
  30. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/tests/test_integration_contract.py +0 -0
  31. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/tests/test_integration_edge_derive.py +0 -0
  32. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/tests/test_integration_edge_write.py +0 -0
  33. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/tests/test_integration_events.py +0 -0
  34. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/tests/test_integration_referential.py +0 -0
  35. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/tests/test_integration_relations.py +0 -0
  36. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/tests/test_integration_unique.py +0 -0
  37. {graphddb_runtime-0.1.0 → graphddb_runtime-0.2.0}/tests/test_relations.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphddb-runtime
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Thin DynamoDB executor for GraphDDB-generated Python repositories (single-operation core, issue #44).
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -152,7 +152,8 @@ class BatchWriteExecutor:
152
152
 
153
153
  ``BatchWriteItem`` carries **no conditions** (DynamoDB has no per-request
154
154
  ``ConditionExpression`` for it) and is **not atomic** — both are properties of
155
- the command-contract ``'batchWrite'`` mode (issue #64). The sleep is injected
155
+ the command-contract ``mode: 'parallel'`` coalesced fan-out (issue #64/#101).
156
+ The sleep is injected
156
157
  so unit tests can observe the backoff schedule without real delays.
157
158
  """
158
159
 
@@ -789,11 +789,11 @@ class GraphDDBRuntime:
789
789
  the method's declared mode:
790
790
 
791
791
  - ``single`` key → the one referenced write op in ``commands``;
792
- - ``keys[]`` + ``transact`` → one ``TransactWriteItems`` (the synthesized
792
+ - ``keys[]`` + ``transaction`` → one ``TransactWriteItems`` (the synthesized
793
793
  per-key ``forEach`` transaction; **atomic**, ≤25, condition-capable). An
794
794
  array of **>25 keys is rejected** — an atomic transaction cannot be split.
795
- - ``keys[]`` + ``batchWrite`` → a ``BatchWriteItem`` (**non-atomic**, **no
796
- conditions**), chunked ≤25 per request with ``UnprocessedItems`` retry.
795
+ - ``keys[]`` + ``parallel`` → a non-atomic per-key fan-out with partial
796
+ success (unconditioned put/delete coalesce into a ``BatchWriteItem``).
797
797
 
798
798
  ``params`` are the mutation values shared across every key (the body's
799
799
  ``params`` argument). For each key the runtime merges ``{**key, **params}``
@@ -810,9 +810,15 @@ class GraphDDBRuntime:
810
810
  shared = dict(params or {})
811
811
 
812
812
  if isinstance(key_or_keys, list):
813
- self._execute_command_batch(
813
+ outcome = self._execute_command_batch(
814
814
  contract_name, method_name, method, key_or_keys, shared
815
815
  )
816
+ # #101 `mode: 'parallel'` returns a partial-success result list; the
817
+ # atomic / batchWrite forms return None (fire-and-forget). Surface the
818
+ # parallel outcome to the caller wrapped as `{"results": [...]}`, matching
819
+ # the TS runtime's `CommandParallelReturn`.
820
+ if outcome is not None:
821
+ return {"results": outcome}
816
822
  return None
817
823
 
818
824
  # Single key → the referenced write surface, driven by key + params.
@@ -894,15 +900,19 @@ class GraphDDBRuntime:
894
900
  method: Mapping[str, Any],
895
901
  keys: List[Mapping[str, Any]],
896
902
  shared: Mapping[str, Any],
897
- ) -> None:
898
- """Apply a command method's array form per its declared batch target."""
903
+ ) -> Optional[List[Dict[str, Any]]]:
904
+ """Apply a command method's array form per its declared `mode` target.
905
+
906
+ Returns ``None`` for the atomic ``transaction`` form (fire-and-forget), or
907
+ the per-op partial-success result list for the #101 ``parallel`` form."""
899
908
  batch = method.get("batch")
900
909
  label = f"{contract_name}.{method_name}"
901
910
  if batch is None:
902
911
  raise ContractArityError(
903
912
  f"command method '{label}' was called with an array of keys, but it "
904
- f"declares no batched-write form. Declare a 'transact' or 'batchWrite' "
905
- f"batch on the method, or call it with a single key."
913
+ f"declares no key-array bulk form. Author the method with "
914
+ f"`mode: 'transaction'` / `mode: 'parallel'`, or call it with a "
915
+ f"single key."
906
916
  )
907
917
  mode = batch.get("mode")
908
918
  if mode == "transaction":
@@ -912,13 +922,57 @@ class GraphDDBRuntime:
912
922
  batch["transaction"], {**dict(shared), "keys": [dict(k) for k in keys]}
913
923
  )
914
924
  return
915
- if mode == "batchWrite":
916
- self._execute_batch_write(label, batch["operation"], keys, shared)
917
- return
925
+ if mode == "parallel":
926
+ # #101 — non-atomic per-key fan-out with partial success. This branch
927
+ # returns a per-op result list; the caller (execute_command_method)
928
+ # surfaces it.
929
+ return self._execute_command_parallel(
930
+ label, batch["operation"], keys, shared
931
+ )
918
932
  raise GraphDDBError( # pragma: no cover - serializer only emits the two above
919
933
  f"{label}: unknown batch resolution mode '{mode}'"
920
934
  )
921
935
 
936
+ def _execute_command_parallel(
937
+ self,
938
+ label: str,
939
+ command_id: str,
940
+ keys: List[Mapping[str, Any]],
941
+ shared: Mapping[str, Any],
942
+ ) -> List[Dict[str, Any]]:
943
+ """#101 ``mode: 'parallel'`` — non-atomic per-key fan-out, partial success.
944
+
945
+ Mirrors the TS ``executeParallelWrites``: when the per-key op carries no
946
+ condition and is put/delete, coalesce into a ``BatchWriteItem`` (chunk ≤25,
947
+ ``UnprocessedItems`` retry) and report every key ``{"ok": True}``; otherwise
948
+ issue each conditional write individually, collecting ``{"ok": bool,
949
+ "error"?}`` per key. A per-op failure NEVER aborts the others. The result
950
+ list is aligned to the input key order.
951
+ """
952
+ if not keys:
953
+ return []
954
+ spec = self._commands.get(command_id)
955
+ if spec is None:
956
+ raise ContractNotFoundError(
957
+ f"{label}: referenced write op '{command_id}' is not present in "
958
+ f"`commands`."
959
+ )
960
+ op_type = spec["type"]
961
+ has_condition = spec.get("condition") is not None
962
+ coalescible = not has_condition and op_type in ("PutItem", "DeleteItem")
963
+ if coalescible:
964
+ self._execute_batch_write(label, command_id, keys, shared)
965
+ return [{"ok": True} for _ in keys]
966
+ results: List[Dict[str, Any]] = []
967
+ for key in keys:
968
+ params = {**dict(key), **dict(shared)}
969
+ try:
970
+ self.execute_command(command_id, params)
971
+ results.append({"ok": True})
972
+ except Exception as exc: # noqa: BLE001 - per-op partial success
973
+ results.append({"ok": False, "error": str(exc)})
974
+ return results
975
+
922
976
  def _execute_batch_write(
923
977
  self,
924
978
  label: str,
@@ -930,8 +984,9 @@ class GraphDDBRuntime:
930
984
 
931
985
  Reuses :class:`BatchWriteExecutor` (chunk ≤25, ``UnprocessedItems`` retry).
932
986
  DynamoDB's ``BatchWriteItem`` supports only ``PutRequest`` / ``DeleteRequest``
933
- — a command whose single-key op is an ``UpdateItem`` cannot resolve its
934
- array form to a ``BatchWriteItem`` (it must declare ``transact``).
987
+ — a command whose single-key op is an ``UpdateItem`` cannot coalesce its
988
+ ``mode: 'parallel'`` array form into a ``BatchWriteItem`` (it falls back to
989
+ per-key ``UpdateItem`` calls, or must declare ``mode: 'transaction'``).
935
990
  """
936
991
  spec = self._commands.get(command_id)
937
992
  if spec is None:
@@ -942,8 +997,8 @@ class GraphDDBRuntime:
942
997
  op_type = spec["type"]
943
998
  if op_type == "UpdateItem":
944
999
  raise GraphDDBError(
945
- f"{label}: 'batchWrite' only supports put / delete (DynamoDB's "
946
- f"BatchWriteItem has no Update request). Declare a 'transact' batch "
1000
+ f"{label}: BatchWriteItem only supports put / delete (DynamoDB's "
1001
+ f"BatchWriteItem has no Update request). Declare `mode: 'transaction'` "
947
1002
  f"for a batched update."
948
1003
  )
949
1004
  requests: List[Dict[str, Any]] = []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphddb-runtime
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Thin DynamoDB executor for GraphDDB-generated Python repositories (single-operation core, issue #44).
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "graphddb-runtime"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Thin DynamoDB executor for GraphDDB-generated Python repositories (single-operation core, issue #44)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -6,11 +6,11 @@ fixtures (``UserCommands`` / ``MembershipCommands`` in operations.json) and
6
6
  asserts the **actual persisted state** after each write:
7
7
 
8
8
  - single key → one write op (update / put / delete);
9
- - key array + 'transact' → one atomic TransactWriteItems (with a per-item
10
- condition: applies on success, rolls the WHOLE batch back on failure);
11
- - key array + 'batchWrite' → a non-atomic BatchWriteItem (put / delete),
12
- chunked > 25 with retry;
13
- - a >25-key 'transact' array is rejected (an atomic batch cannot be split).
9
+ - key array + `mode: 'transaction'` → one atomic TransactWriteItems (with a
10
+ per-item condition: applies on success, rolls the WHOLE batch back on failure);
11
+ - key array + `mode: 'parallel'` → a non-atomic per-key fan-out (put / delete
12
+ coalesce into BatchWriteItem), chunked > 25 with retry;
13
+ - a >25-key `mode: 'transaction'` array is rejected (an atomic batch cannot be split).
14
14
 
15
15
  This mirrors the TS suite (__tests__/integration/command-runtime.test.ts) over
16
16
  the **same SSoT**, proving the two runtimes produce identical effects.
@@ -147,7 +147,10 @@ def test_single_update_applies(rt, client):
147
147
 
148
148
  def test_single_put_then_delete(rt, client):
149
149
  rt.execute_command_method(
150
- "MembershipCommands", "add", {"groupId": "eng", "userId": "solo"}, {"role": "lead"}
150
+ "MembershipCommands",
151
+ "add",
152
+ {"groupId": "eng", "userId": "solo"},
153
+ {"role": "lead", "joinedAt": "2021-06-01T00:00:00.000Z"},
151
154
  )
152
155
  row = _membership(client, "eng", "solo")
153
156
  assert row is not None and row["role"]["S"] == "lead"
@@ -172,9 +175,21 @@ def test_transact_array_applies_atomically(rt, client):
172
175
 
173
176
 
174
177
  def test_transact_condition_rolls_back_whole_batch_on_failure(rt, client):
175
- # Make e 'pending' so its `status = active` condition fails; the atomic batch
176
- # must then roll d back too.
177
- rt.execute_command_method("UserCommands", "disable", {"userId": "e"}, {"status": "pending"})
178
+ # Make e 'pending' so its literal `status = active` condition fails; the atomic
179
+ # batch must then roll d back too. (`disable`'s input is `literal('disabled')`,
180
+ # so the 'pending' precondition is written with a raw put rather than the command.)
181
+ client.put_item(
182
+ TableName=TABLE,
183
+ Item={
184
+ "PK": {"S": "USER#e"},
185
+ "SK": {"S": "PROFILE"},
186
+ "userId": {"S": "e"},
187
+ "name": {"S": "User e"},
188
+ "email": {"S": "e@x.com"},
189
+ "status": {"S": "pending"},
190
+ "createdAt": {"S": "2021-01-01T00:00:00.000Z"},
191
+ },
192
+ )
178
193
  assert _user_status(client, "e") == "pending"
179
194
 
180
195
  with pytest.raises(OperationExecutionError):
@@ -182,7 +197,7 @@ def test_transact_condition_rolls_back_whole_batch_on_failure(rt, client):
182
197
  "UserCommands",
183
198
  "disableIfActive",
184
199
  [{"userId": "d"}, {"userId": "e"}],
185
- {"status": "disabled", "expected": "active"},
200
+ {"status": "disabled"},
186
201
  )
187
202
  # d unchanged (rolled back); e unchanged.
188
203
  assert _user_status(client, "d") == "active"
@@ -194,7 +209,7 @@ def test_transact_condition_applies_when_all_hold(rt, client):
194
209
  "UserCommands",
195
210
  "disableIfActive",
196
211
  [{"userId": "f"}],
197
- {"status": "disabled", "expected": "active"},
212
+ {"status": "disabled"},
198
213
  )
199
214
  assert _user_status(client, "f") == "disabled"
200
215
 
@@ -205,12 +220,14 @@ def test_transact_rejects_over_25_keys(rt, client):
205
220
  rt.execute_command_method("UserCommands", "disable", keys, {"status": "disabled"})
206
221
 
207
222
 
208
- # ── array + batchWrite → non-atomic BatchWriteItem (chunked > 25) ────────────
223
+ # ── array + mode:'parallel' → non-atomic BatchWriteItem (chunked > 25) ───────
209
224
 
210
225
 
211
226
  def test_batch_write_puts_and_chunks_over_25(rt, client):
212
227
  keys = [{"groupId": "big", "userId": f"m{i}"} for i in range(30)]
213
- rt.execute_command_method("MembershipCommands", "add", keys, {"role": "member"})
228
+ rt.execute_command_method(
229
+ "MembershipCommands", "add", keys, {"role": "member", "joinedAt": "2021-06-01T00:00:00.000Z"}
230
+ )
214
231
  assert _membership(client, "big", "m0")["role"]["S"] == "member"
215
232
  assert _membership(client, "big", "m24")["role"]["S"] == "member"
216
233
  assert _membership(client, "big", "m29")["role"]["S"] == "member"
@@ -218,7 +235,9 @@ def test_batch_write_puts_and_chunks_over_25(rt, client):
218
235
 
219
236
  def test_batch_write_deletes(rt, client):
220
237
  keys = [{"groupId": "big", "userId": f"m{i}"} for i in range(30)]
221
- rt.execute_command_method("MembershipCommands", "add", keys, {"role": "member"})
238
+ rt.execute_command_method(
239
+ "MembershipCommands", "add", keys, {"role": "member", "joinedAt": "2021-06-01T00:00:00.000Z"}
240
+ )
222
241
  rt.execute_command_method("MembershipCommands", "remove", keys)
223
242
  assert _membership(client, "big", "m0") is None
224
243
  assert _membership(client, "big", "m29") is None
@@ -901,7 +901,7 @@ def test_command_method_single_update_runs_one_update_item():
901
901
 
902
902
 
903
903
  def test_command_method_transact_array_runs_one_transaction():
904
- """A key array + 'transact' → ONE TransactWriteItems (one item per key)."""
904
+ """A key array + `mode: 'transaction'` → ONE TransactWriteItems (one item per key)."""
905
905
  client = FakeClient()
906
906
  rt = make_runtime(client)
907
907
  rt.execute_command_method(
@@ -920,26 +920,28 @@ def test_command_method_transact_array_runs_one_transaction():
920
920
 
921
921
 
922
922
  def test_command_method_transact_carries_per_item_condition():
923
- """A conditional 'transact' batch attaches the equality condition per item."""
923
+ """A conditional `mode: 'transaction'` batch attaches the equality condition per item."""
924
924
  client = FakeClient()
925
925
  rt = make_runtime(client)
926
926
  rt.execute_command_method(
927
927
  "UserCommands",
928
928
  "disableIfActive",
929
929
  [{"userId": "a"}, {"userId": "b"}],
930
- {"status": "disabled", "expected": "active"},
930
+ {"status": "disabled"},
931
931
  )
932
932
  method, req = client.calls[0]
933
933
  assert method == "transact_write_items"
934
934
  update = req["TransactItems"][0]["Update"]
935
935
  assert "ConditionExpression" in update
936
- assert "active" in [
937
- _DESER.deserialize(v) for v in update["ExpressionAttributeValues"].values()
938
- ]
936
+ # #101 descriptor form: the gate is a concrete-literal equality (status == 'active');
937
+ # the written value is the 'disabled' status param. Both appear per item.
938
+ values = [_DESER.deserialize(v) for v in update["ExpressionAttributeValues"].values()]
939
+ assert "active" in values
940
+ assert "disabled" in values
939
941
 
940
942
 
941
943
  def test_command_method_transact_rejects_over_25_keys():
942
- """A >25-key 'transact' array is rejected — an atomic tx cannot be split."""
944
+ """A >25-key `mode: 'transaction'` array is rejected — an atomic tx cannot be split."""
943
945
  client = FakeClient()
944
946
  rt = make_runtime(client)
945
947
  keys = [{"userId": f"u{i}"} for i in range(26)]
@@ -949,32 +951,38 @@ def test_command_method_transact_rejects_over_25_keys():
949
951
  assert all(m != "transact_write_items" for m, _ in client.calls)
950
952
 
951
953
 
952
- def test_command_method_batch_write_array_runs_batch_write_item():
953
- """A key array + 'batchWrite' a BatchWriteItem of PutRequests (no conditions)."""
954
+ def test_command_method_parallel_guarded_create_runs_individual_conditional_puts():
955
+ """#101 `mode: 'parallel'` + a guarded create (attribute_not_exists) → INDIVIDUAL
956
+ conditional PutItem writes (not a condition-less BatchWriteItem), with per-key
957
+ partial-success results aligned to key order."""
954
958
  client = FakeClient()
955
959
  rt = make_runtime(client)
956
- rt.execute_command_method(
960
+ out = rt.execute_command_method(
957
961
  "MembershipCommands",
958
962
  "add",
959
963
  [{"groupId": "eng", "userId": "a"}, {"groupId": "eng", "userId": "b"}],
960
- {"role": "member"},
964
+ {"role": "member", "joinedAt": "1970-01-01T00:00:00Z"},
961
965
  )
962
- assert len(client.calls) == 1
963
- method, req = client.calls[0]
964
- assert method == "batch_write_item"
965
- (table, requests), = req["RequestItems"].items()
966
- assert len(requests) == 2
967
- item0 = plain(requests[0]["PutRequest"]["Item"])
966
+ # A guarded create carries a condition, so the parallel path issues one PutItem
967
+ # per key (concurrently) — never a coalesced BatchWriteItem.
968
+ methods = [m for m, _ in client.calls]
969
+ assert methods == ["put_item", "put_item"]
970
+ first = client.calls[0][1]
971
+ assert "ConditionExpression" in first
972
+ item0 = plain(first["Item"])
968
973
  assert item0["PK"] == "GROUP#eng"
969
974
  assert item0["SK"] == "USER#a"
970
975
  assert item0["role"] == "member"
976
+ # Partial-success result: both keys ok, aligned to input order.
977
+ assert out == {"results": [{"ok": True}, {"ok": True}]}
971
978
 
972
979
 
973
- def test_command_method_batch_write_delete_runs_delete_requests():
974
- """A 'batchWrite' delete resolves to DeleteRequests keyed per input key."""
980
+ def test_command_method_parallel_unconditioned_delete_coalesces_batch_write():
981
+ """#101 `mode: 'parallel'` + an UNconditioned delete coalesces into a
982
+ BatchWriteItem (DeleteRequests), reporting every key ok."""
975
983
  client = FakeClient()
976
984
  rt = make_runtime(client)
977
- rt.execute_command_method(
985
+ out = rt.execute_command_method(
978
986
  "MembershipCommands",
979
987
  "remove",
980
988
  [{"groupId": "eng", "userId": "a"}, {"groupId": "eng", "userId": "b"}],
@@ -986,6 +994,7 @@ def test_command_method_batch_write_delete_runs_delete_requests():
986
994
  "PK": "GROUP#eng",
987
995
  "SK": "USER#a",
988
996
  }
997
+ assert out == {"results": [{"ok": True}, {"ok": True}]}
989
998
 
990
999
 
991
1000
  def test_command_method_unknown_contract_raises():