From 34202bae664dedaedfa1fa270afd00a0648fb5ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 13:05:33 +0000 Subject: [PATCH 1/5] Initial plan From 75802754fad15c38a3d30144dda592b22421d55c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 13:10:52 +0000 Subject: [PATCH 2/5] feat: add --sort-by FIELDS option to ls command --- src/con_duct/cli.py | 7 +++++++ src/con_duct/ls.py | 15 +++++++++++++++ test/test_ls.py | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/src/con_duct/cli.py b/src/con_duct/cli.py index e3cc655b..195cda8e 100644 --- a/src/con_duct/cli.py +++ b/src/con_duct/cli.py @@ -466,6 +466,13 @@ def _create_ls_parser() -> argparse.ArgumentParser: action="store_true", help="List entries in reverse order (most recent first).", ) + parser.add_argument( + "--sort-by", + nargs="+", + metavar="FIELD", + choices=LS_FIELD_CHOICES, + help=f"Sort results by one or more fields. Available choices: {', '.join(sorted(LS_FIELD_CHOICES))}.", + ) return parser diff --git a/src/con_duct/ls.py b/src/con_duct/ls.py index 403eed6a..a45adf82 100644 --- a/src/con_duct/ls.py +++ b/src/con_duct/ls.py @@ -231,6 +231,21 @@ def ls(args: argparse.Namespace) -> int: ) info_files = [path for path in args.paths if is_info_file(path)] run_data_raw = load_duct_runs(info_files, args.eval_filter) + + sort_by = getattr(args, "sort_by", None) + if sort_by: + flat_data = [_flatten_dict(d) for d in run_data_raw] + run_data_raw = [ + item + for _, item in sorted( + zip(flat_data, run_data_raw), + key=lambda x: tuple( + (x[0].get(k) is None, x[0].get(k) if x[0].get(k) is not None else "") + for k in sort_by + ), + ) + ] + output_rows = process_run_data(run_data_raw, args.fields, formatter) if args.reverse: diff --git a/test/test_ls.py b/test/test_ls.py index 7d7dd232..deab4a56 100644 --- a/test/test_ls.py +++ b/test/test_ls.py @@ -223,6 +223,7 @@ def _run_ls( format=fmt, func=ls, reverse=False, + sort_by=None, ) buf = StringIO() with contextlib.redirect_stdout(buf): @@ -255,6 +256,7 @@ def test_ls_with_filter(self) -> None: format="summaries", func=ls, reverse=False, + sort_by=None, ) result = self._run_ls(paths, "summaries", args) @@ -383,9 +385,48 @@ def test_ls_reverse(self) -> None: format="json", func=ls, reverse=True, + sort_by=None, ) result_reversed = self._run_ls(paths, "json", args) parsed_reversed = json.loads(result_reversed) prefixes_reversed = [row["prefix"] for row in parsed_reversed] assert prefixes_reversed == list(reversed(prefixes_normal)) + + def test_ls_sort_by(self) -> None: + """Test --sort-by flag sorts entries by the specified field.""" + paths = ["file1_info.json", "file2_info.json"] + + args = argparse.Namespace( + paths=[os.path.join(self.temp_dir.name, path) for path in paths], + colors=False, + fields=["prefix", "schema_version"], + eval_filter=None, + format="json", + func=ls, + reverse=False, + sort_by=["prefix"], + ) + result = self._run_ls(paths, "json", args) + parsed = json.loads(result) + prefixes = [row["prefix"] for row in parsed] + assert prefixes == sorted(prefixes) + + def test_ls_sort_by_reverse(self) -> None: + """Test --sort-by combined with --reverse gives descending order.""" + paths = ["file1_info.json", "file2_info.json"] + + args = argparse.Namespace( + paths=[os.path.join(self.temp_dir.name, path) for path in paths], + colors=False, + fields=["prefix", "schema_version"], + eval_filter=None, + format="json", + func=ls, + reverse=True, + sort_by=["prefix"], + ) + result = self._run_ls(paths, "json", args) + parsed = json.loads(result) + prefixes = [row["prefix"] for row in parsed] + assert prefixes == sorted(prefixes, reverse=True) From 7d69807efd3fc31d7af31c130e5abceee86601a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 15:23:44 +0000 Subject: [PATCH 3/5] refactor: address review feedback on --sort-by implementation --- src/con_duct/cli.py | 4 +-- src/con_duct/ls.py | 11 +++---- test/test_ls.py | 71 ++++++++++++++++++++++----------------------- 3 files changed, 43 insertions(+), 43 deletions(-) diff --git a/src/con_duct/cli.py b/src/con_duct/cli.py index 195cda8e..b11d3710 100644 --- a/src/con_duct/cli.py +++ b/src/con_duct/cli.py @@ -464,14 +464,14 @@ def _create_ls_parser() -> argparse.ArgumentParser: parser.add_argument( "--reverse", action="store_true", - help="List entries in reverse order (most recent first).", + help="List entries in reverse order.", ) parser.add_argument( "--sort-by", nargs="+", metavar="FIELD", choices=LS_FIELD_CHOICES, - help=f"Sort results by one or more fields. Available choices: {', '.join(sorted(LS_FIELD_CHOICES))}.", + help="Sort results by one or more fields. See --fields for available choices.", ) return parser diff --git a/src/con_duct/ls.py b/src/con_duct/ls.py index a45adf82..41017fd1 100644 --- a/src/con_duct/ls.py +++ b/src/con_duct/ls.py @@ -232,15 +232,16 @@ def ls(args: argparse.Namespace) -> int: info_files = [path for path in args.paths if is_info_file(path)] run_data_raw = load_duct_runs(info_files, args.eval_filter) - sort_by = getattr(args, "sort_by", None) - if sort_by: - flat_data = [_flatten_dict(d) for d in run_data_raw] + if sort_by := getattr(args, "sort_by", None): run_data_raw = [ item for _, item in sorted( - zip(flat_data, run_data_raw), + zip(map(_flatten_dict, run_data_raw), run_data_raw), key=lambda x: tuple( - (x[0].get(k) is None, x[0].get(k) if x[0].get(k) is not None else "") + ( + x[0].get(k) is None, + x[0].get(k) if x[0].get(k) is not None else "", + ) for k in sort_by ), ) diff --git a/test/test_ls.py b/test/test_ls.py index deab4a56..fb2bd6c3 100644 --- a/test/test_ls.py +++ b/test/test_ls.py @@ -393,40 +393,39 @@ def test_ls_reverse(self) -> None: assert prefixes_reversed == list(reversed(prefixes_normal)) - def test_ls_sort_by(self) -> None: - """Test --sort-by flag sorts entries by the specified field.""" - paths = ["file1_info.json", "file2_info.json"] - - args = argparse.Namespace( - paths=[os.path.join(self.temp_dir.name, path) for path in paths], - colors=False, - fields=["prefix", "schema_version"], - eval_filter=None, - format="json", - func=ls, - reverse=False, - sort_by=["prefix"], - ) - result = self._run_ls(paths, "json", args) - parsed = json.loads(result) - prefixes = [row["prefix"] for row in parsed] - assert prefixes == sorted(prefixes) - - def test_ls_sort_by_reverse(self) -> None: - """Test --sort-by combined with --reverse gives descending order.""" - paths = ["file1_info.json", "file2_info.json"] - args = argparse.Namespace( - paths=[os.path.join(self.temp_dir.name, path) for path in paths], - colors=False, - fields=["prefix", "schema_version"], - eval_filter=None, - format="json", - func=ls, - reverse=True, - sort_by=["prefix"], - ) - result = self._run_ls(paths, "json", args) - parsed = json.loads(result) - prefixes = [row["prefix"] for row in parsed] - assert prefixes == sorted(prefixes, reverse=True) +@pytest.mark.parametrize("reverse", [False, True]) +def test_ls_sort_by(reverse: bool, tmp_path: Any) -> None: + """Test --sort-by flag sorts entries by the specified field, with optional reverse.""" + files = { + "file1_info.json": { + "schema_version": MINIMUM_SCHEMA_VERSION, + "execution_summary": {}, + "prefix": "test1", + }, + "file2_info.json": { + "schema_version": MINIMUM_SCHEMA_VERSION, + "execution_summary": {}, + "prefix": "test2", + }, + } + for filename, content in files.items(): + path = tmp_path / filename + path.write_text(json.dumps(content)) + + paths = [str(tmp_path / f) for f in files] + args = argparse.Namespace( + paths=paths, + colors=False, + fields=["prefix", "schema_version"], + eval_filter=None, + format="json", + func=ls, + reverse=reverse, + sort_by=["prefix"], + ) + buf = StringIO() + with contextlib.redirect_stdout(buf): + assert ls(args) == 0 + prefixes = [row["prefix"] for row in json.loads(buf.getvalue().strip())] + assert prefixes == sorted(prefixes, reverse=reverse) From 055b98eed1ac3eb7a9cb8d407e470daec750bb67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 14:05:11 +0000 Subject: [PATCH 4/5] test: add test for sorting by non-displayed field --- test/test_ls.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/test/test_ls.py b/test/test_ls.py index fb2bd6c3..b201cbd3 100644 --- a/test/test_ls.py +++ b/test/test_ls.py @@ -429,3 +429,47 @@ def test_ls_sort_by(reverse: bool, tmp_path: Any) -> None: assert ls(args) == 0 prefixes = [row["prefix"] for row in json.loads(buf.getvalue().strip())] assert prefixes == sorted(prefixes, reverse=reverse) + + +def test_ls_sort_by_non_displayed_field(tmp_path: Any) -> None: + """Test --sort-by works for fields not included in --fields (not displayed).""" + # Three runs with commands in non-alphabetical order; paths also in non-alphabetical + # order so glob/filesystem order cannot accidentally pass the test. + entries = [ + ("run_b_info.json", "cmd_b"), + ("run_a_info.json", "cmd_a"), + ("run_c_info.json", "cmd_c"), + ] + for filename, command in entries: + (tmp_path / filename).write_text( + json.dumps( + { + "schema_version": MINIMUM_SCHEMA_VERSION, + "execution_summary": {}, + "command": command, + } + ) + ) + + # paths deliberately in creation order (b, a, c) — not sorted by command + paths = [str(tmp_path / filename) for filename, _ in entries] + args = argparse.Namespace( + paths=paths, + colors=False, + fields=["prefix"], # "command" is intentionally NOT in displayed fields + eval_filter=None, + format="json", + func=ls, + reverse=False, + sort_by=["command"], + ) + buf = StringIO() + with contextlib.redirect_stdout(buf): + assert ls(args) == 0 + rows = json.loads(buf.getvalue().strip()) + # Output order should match sorted command order: cmd_a, cmd_b, cmd_c + # which corresponds to run_a, run_b, run_c prefixes + prefixes = [row["prefix"] for row in rows] + assert "run_a_" in prefixes[0] + assert "run_b_" in prefixes[1] + assert "run_c_" in prefixes[2] From 496e92f9aeaeddfb1d81d189f5b06d702f5a7ea8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:37:04 +0000 Subject: [PATCH 5/5] test: parametrize sort_by over all LS_FIELD_CHOICES; fix sort key for list/dict values --- src/con_duct/ls.py | 15 +++++++++- test/test_ls.py | 71 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/con_duct/ls.py b/src/con_duct/ls.py index 41017fd1..ba48454b 100644 --- a/src/con_duct/ls.py +++ b/src/con_duct/ls.py @@ -149,6 +149,19 @@ def _flatten_dict(d: Dict[str, Any]) -> Dict[str, Any]: return dict(items) +def _make_sort_value(v: Any) -> Any: + """Convert a field value to a sortable representation. + + Lists and dicts are serialised to a JSON string to give a stable + deterministic ordering without raising TypeError. + """ + if v is None: + return "" + if isinstance(v, (list, dict)): + return json.dumps(v, sort_keys=True) + return v + + def _restrict_row(field_list: List[str], row: Dict[str, Any]) -> OrderedDict[str, Any]: restricted: OrderedDict[str, Any] = OrderedDict() # prefix is the "primary key", its the only field guaranteed to be unique. @@ -240,7 +253,7 @@ def ls(args: argparse.Namespace) -> int: key=lambda x: tuple( ( x[0].get(k) is None, - x[0].get(k) if x[0].get(k) is not None else "", + _make_sort_value(x[0].get(k)), ) for k in sort_by ), diff --git a/test/test_ls.py b/test/test_ls.py index b201cbd3..46efd393 100644 --- a/test/test_ls.py +++ b/test/test_ls.py @@ -12,6 +12,7 @@ from con_duct._constants import __schema_version__ from con_duct._formatter import SummaryFormatter from con_duct.ls import ( + LS_FIELD_CHOICES, MINIMUM_SCHEMA_VERSION, _flatten_dict, _restrict_row, @@ -473,3 +474,73 @@ def test_ls_sort_by_non_displayed_field(tmp_path: Any) -> None: assert "run_a_" in prefixes[0] assert "run_b_" in prefixes[1] assert "run_c_" in prefixes[2] + + +@pytest.mark.parametrize("sort_field", LS_FIELD_CHOICES) +def test_ls_sort_by_each_field(sort_field: str, tmp_path: Any) -> None: + """Sorting by every LS_FIELD_CHOICES field must not crash and must produce sorted output.""" + # Choose 3 values for the sort field, assigned to run1/run2/run3 in the order listed + # (val1 to run1, val2 to run2, val3 to run3). After sorting, the expected output + # order must be run2, run1, run3 (smallest to largest). + if sort_field == "prefix": + # prefix is derived from the file path by load_duct_runs, not from JSON content. + filenames = ["run_b_info.json", "run_a_info.json", "run_c_info.json"] + base = {"schema_version": __schema_version__, "execution_summary": {}} + for fn in filenames: + (tmp_path / fn).write_text(json.dumps(base)) + paths = [str(tmp_path / fn) for fn in filenames] + # sorted prefix order: ...run_a_ < ...run_b_ < ...run_c_ + expected_fragments = ["run_a_", "run_b_", "run_c_"] + else: + # Assign sort values so run2 < run1 < run3. + if sort_field == "schema_version": + # Must be valid version strings >= MINIMUM_SCHEMA_VERSION. + val1, val2, val3 = "0.2.1", "0.2.0", "0.2.2" + elif sort_field == "gpu": + # gpu is a list[dict]; _make_sort_value serialises to JSON string. + val1 = [{"name": "gpu_b"}] + val2 = [{"name": "gpu_a"}] + val3 = [{"name": "gpu_c"}] + else: + val1, val2, val3 = "sort_b", "sort_a", "sort_c" + + def build_run(val: Any) -> Dict[str, Any]: + # Use the current schema version so ensure_compliant_schema returns early + # and does not overwrite any fields we are setting for the test. + data: Dict[str, Any] = { + "schema_version": __schema_version__, + "execution_summary": {}, + } + if sort_field == "schema_version": + data["schema_version"] = val + else: + # Place at top level; _flatten_dict will expose it for sorting. + data[sort_field] = val + return data + + filenames = [f"run{i}_info.json" for i in range(1, 4)] + for fn, val in zip(filenames, [val1, val2, val3]): + (tmp_path / fn).write_text(json.dumps(build_run(val))) + paths = [str(tmp_path / fn) for fn in filenames] + # sorted: val2(run2) < val1(run1) < val3(run3) + expected_fragments = ["run2_", "run1_", "run3_"] + + args = argparse.Namespace( + paths=paths, + colors=False, + fields=["prefix"], + eval_filter=None, + format="json", + func=ls, + reverse=False, + sort_by=[sort_field], + ) + buf = StringIO() + with contextlib.redirect_stdout(buf): + assert ls(args) == 0 + prefixes = [row["prefix"] for row in json.loads(buf.getvalue().strip())] + for i, fragment in enumerate(expected_fragments): + assert fragment in prefixes[i], ( + f"sort_by={sort_field!r}: position {i} expected '{fragment}' in prefix," + f" got '{prefixes[i]}'" + )