Skip to content

Commit ae1c29d

Browse files
Ruari-Phippsclaude
andauthored
fix: sort resource collections in YAML and register fetch command (#291)
## Summary Sorts collections alphabetically during YAML serialization so pulls produce stable, diff-friendly output. Also registers `FetchCommand` in the CLI command list — it was implemented but never wired up, so `poly fetch` was unavailable. ## Motivation Several collections are built by iterating a map in the platform projection — flow step conditions, API integration operations, test case function call assertions and their arguments, and the integration/operation keys of test case API mocks. Their serialized order followed whatever the map iteration gave, so unrelated pulls could reorder blocks in a resource's YAML and create noisy diffs. Separately, the `fetch` command existed in `cli_commands/sync.py` but was missing from `COMMANDS`, so it never appeared in the CLI. ## Changes - `FlowStep.to_yaml_dict` sorts conditions by `name` (matches the existing `sorted(self.extracted_entities)` behaviour) - `ApiIntegration.to_yaml_dict` sorts operations by `name` - `FunctionCallAssertion.to_yaml_dict` sorts arguments by `parameter_name`, and `TestCaseAssertion.to_yaml_dict` sorts function call assertions by `name` - `TestCaseApiMocks.to_yaml_dict` sorts integration and operation names; the rules within an operation keep their order, since they are a sequence and `repeat` depends on it - Add `FetchCommand` to the `COMMANDS` list in `cli.py` so `poly fetch` is registered - Add unit tests covering each ordering, including one asserting mock rule order is preserved - `uv.lock` version bump picked up from the 0.44.4 release ## Test strategy - [x] Added/updated unit tests - [x] Manual CLI testing (`poly --help` now lists `fetch`) - [ ] Tested against a live Agent Studio project - [ ] N/A (docs, config, or trivial change) ## Checklist - [x] `ruff check .` and `ruff format --check .` pass - [x] `pytest` passes (1356 passed) - [x] No breaking changes to the `poly` CLI interface (or migration path documented) - [x] Commit messages follow [conventional commits](https://www.conventionalcommits.org/) ## Screenshots / Logs ``` $ uv run poly --help | grep fetch fetch Fetch the latest project state from Agent Studio ``` --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3b58b32 commit ae1c29d

6 files changed

Lines changed: 128 additions & 8 deletions

File tree

‎src/poly/cli.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from poly.cli_commands.rtc import RTCCommand
2525
from poly.cli_commands.sync import (
2626
DiffCommand,
27+
FetchCommand,
2728
FormatCommand,
2829
PullCommand,
2930
PushCommand,
@@ -45,6 +46,7 @@
4546
StudioCommand,
4647
ProjectCommand,
4748
TemplateCommand,
49+
FetchCommand,
4850
PullCommand,
4951
PushCommand,
5052
StatusCommand,

‎src/poly/resources/api_integration.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,9 @@ def to_yaml_dict(self) -> dict:
338338
"name": self.name,
339339
"description": self.description,
340340
"environments": self.environments.to_yaml_dict(),
341-
"operations": [op.to_yaml_dict() for op in self.operations],
341+
"operations": [
342+
op.to_yaml_dict() for op in sorted(self.operations, key=lambda op: op.name)
343+
],
342344
}
343345

344346
@classmethod

‎src/poly/resources/flows.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,10 @@ def to_yaml_dict(self) -> dict:
430430
output.update(flow_settings_dict)
431431

432432
if self.step_type == StepType.DEFAULT_STEP:
433-
output["conditions"] = [condition.to_yaml_dict() for condition in self.conditions]
433+
output["conditions"] = [
434+
condition.to_yaml_dict()
435+
for condition in sorted(self.conditions, key=lambda condition: condition.name)
436+
]
434437
output["extracted_entities"] = sorted(self.extracted_entities)
435438

436439
output["prompt"] = self.prompt

‎src/poly/resources/test_suite.py‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,13 @@ def __init__(self, name: str, arguments: list[FunctionCallArgumentAssertion | di
125125
]
126126

127127
def to_yaml_dict(self) -> dict:
128-
return {"name": self.name, "arguments": [arg.to_yaml_dict() for arg in self.arguments]}
128+
return {
129+
"name": self.name,
130+
"arguments": [
131+
arg.to_yaml_dict()
132+
for arg in sorted(self.arguments, key=lambda arg: arg.parameter_name)
133+
],
134+
}
129135

130136
def to_proto(self) -> FunctionCallAssertionProto:
131137
return FunctionCallAssertionProto(
@@ -166,7 +172,8 @@ def to_yaml_dict(self) -> dict:
166172
response["prompt_assertions"] = self.prompts
167173
if self.function_calls:
168174
response["function_call_assertions"] = [
169-
function_call.to_yaml_dict() for function_call in self.function_calls
175+
function_call.to_yaml_dict()
176+
for function_call in sorted(self.function_calls, key=lambda call: call.name)
170177
]
171178
return response
172179

@@ -481,12 +488,16 @@ class TestCaseApiMocks:
481488
mocks: dict[str, dict[str, list[ApiResponseRule]]] = field(default_factory=dict)
482489

483490
def to_yaml_dict(self) -> dict:
491+
# Integration and operation names are sorted so pulls produce stable YAML; the
492+
# rules within an operation are a sequence (see `repeat`) and keep their order.
484493
return {
485494
integration_name: {
486-
operation_name: [rule.to_yaml_dict() for rule in rules]
487-
for operation_name, rules in operations.items()
495+
operation_name: [
496+
rule.to_yaml_dict() for rule in self.mocks[integration_name][operation_name]
497+
]
498+
for operation_name in sorted(self.mocks[integration_name])
488499
}
489-
for integration_name, operations in self.mocks.items()
500+
for integration_name in sorted(self.mocks)
490501
}
491502

492503
@classmethod

‎src/poly/tests/resources_test.py‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2858,6 +2858,35 @@ def test_get_raw_no_code_step(self):
28582858
"""Test that raw property returns correct YAML representation for no code step."""
28592859
self.assertEqual(TEST_NO_CODE_FLOW_STEP.raw, FLOW_NO_CODE_STEP_RAW)
28602860

2861+
def test_conditions_sorted_by_name(self):
2862+
"""Test that conditions are serialized in alphabetical order by name."""
2863+
step = FlowStep(
2864+
resource_id="flow-123_step-1",
2865+
step_id="step-1",
2866+
name="Test Step",
2867+
flow_id="flow-123",
2868+
flow_name="Test Flow",
2869+
step_type=StepType.DEFAULT_STEP,
2870+
conditions=[
2871+
Condition(
2872+
resource_id=f"cond-{name}",
2873+
name=name,
2874+
description="",
2875+
condition_type=ConditionType.STEP,
2876+
child_step="step-2",
2877+
step_id="step-1",
2878+
flow_id="flow-123",
2879+
)
2880+
for name in ["zebra", "apple", "monkey"]
2881+
],
2882+
prompt="Hello, how can I help you?",
2883+
position={"x": 0.0, "y": 0.0},
2884+
extracted_entities=[],
2885+
)
2886+
2887+
condition_names = [c["name"] for c in step.to_yaml_dict()["conditions"]]
2888+
self.assertEqual(condition_names, ["apple", "monkey", "zebra"])
2889+
28612890
def test_to_pretty(self):
28622891
"""Test converting flow step to pretty format with function name mapping."""
28632892
resource_mappings = [
@@ -5429,6 +5458,22 @@ def test_api_integration_to_yaml_dict_and_from_yaml_dict(self):
54295458
self.assertEqual(i2.operations[0].name, "get")
54305459
self.assertEqual(i2.operations[0].resource_id, "")
54315460

5461+
def test_api_integration_operations_sorted_by_name(self):
5462+
"""Operations serialize in alphabetical order by name."""
5463+
integration = ApiIntegration(
5464+
resource_id="int-1",
5465+
name="TestAPI",
5466+
operations=[
5467+
ApiIntegrationOperation(
5468+
resource_id=f"op-{name}", name=name, method="GET", resource="/x"
5469+
)
5470+
for name in ["refund", "charge", "get_customer"]
5471+
],
5472+
)
5473+
5474+
operation_names = [op["name"] for op in integration.to_yaml_dict()["operations"]]
5475+
self.assertEqual(operation_names, ["charge", "get_customer", "refund"])
5476+
54325477
def test_api_integration_build_protos(self):
54335478
"""ApiIntegration build_create_proto, build_update_proto, build_delete_proto set id and environments."""
54345479
env = ApiIntegrationEnvironments.from_dict(
@@ -7486,6 +7531,39 @@ def _sample_test_case(self) -> TestCase:
74867531
language="en-GB",
74877532
)
74887533

7534+
def test_assertions_sorted_by_name(self):
7535+
"""Function call assertions and their arguments serialize in alphabetical order."""
7536+
assertions = TestCaseAssertion(
7537+
resource_id="TEST-ordering",
7538+
name="assertions",
7539+
prompts=[],
7540+
function_calls=[
7541+
FunctionCallAssertion(
7542+
name=name,
7543+
arguments=[
7544+
FunctionCallArgumentAssertion(
7545+
parameter_name=parameter_name,
7546+
expected_value="value",
7547+
value_type="string",
7548+
)
7549+
for parameter_name in ["zebra", "apple", "monkey"]
7550+
],
7551+
)
7552+
for name in ["transfer_call", "book_appointment", "lookup_order"]
7553+
],
7554+
)
7555+
7556+
function_calls = assertions.to_yaml_dict()["function_call_assertions"]
7557+
7558+
self.assertEqual(
7559+
[call["name"] for call in function_calls],
7560+
["book_appointment", "lookup_order", "transfer_call"],
7561+
)
7562+
self.assertEqual(
7563+
[arg["parameter_name"] for arg in function_calls[0]["arguments"]],
7564+
["apple", "monkey", "zebra"],
7565+
)
7566+
74897567
def test_to_yaml_dict_from_yaml_dict_roundtrip(self):
74907568
test_case = self._sample_test_case()
74917569
yaml_dict = test_case.to_yaml_dict()
@@ -8274,6 +8352,30 @@ def test_omits_empty_values_from_yaml(self):
82748352

82758353
self.assertNotIn("api_mocks", yaml_dict)
82768354

8355+
def test_yaml_sorts_integration_and_operation_names(self):
8356+
"""Integration/operation names serialize alphabetically; rule order is preserved."""
8357+
api_mocks = self._api_mocks(
8358+
{
8359+
"payments": {
8360+
"refund": [ApiResponseRule(respond=ApiResponse(status=200))],
8361+
"charge": [
8362+
ApiResponseRule(respond=ApiResponse(status=500), repeat=2),
8363+
ApiResponseRule(respond=ApiResponse(status=201)),
8364+
],
8365+
},
8366+
"crm": {"get_customer": [ApiResponseRule(respond=ApiResponse(status=200))]},
8367+
}
8368+
)
8369+
8370+
mocks_yaml = self._test_case(api_mocks=api_mocks).to_yaml_dict()["api_mocks"]
8371+
8372+
self.assertEqual(list(mocks_yaml), ["crm", "payments"])
8373+
self.assertEqual(list(mocks_yaml["payments"]), ["charge", "refund"])
8374+
self.assertEqual(
8375+
mocks_yaml["payments"]["charge"],
8376+
[{"respond": {"status": 500}, "repeat": 2}, {"respond": {"status": 201}}],
8377+
)
8378+
82778379
def _operation_mock(self, **overrides) -> TestCaseApiOperationMock:
82788380
defaults = {
82798381
"resource_id": f"{self.RESOURCE_ID}:crm:get_customer",

‎uv.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)