From a5148d2c65cb21628dfa5ccd4177531d30614fa6 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/4] Initial plan From c34300ba500a821270c80fd8f1aa3dcaf1f6e8da 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/4] 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 258d00aa..94b82107 100644 --- a/src/con_duct/cli.py +++ b/src/con_duct/cli.py @@ -456,6 +456,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 68ff39ab03decba609ebeeb7fef390bc11f3e06e 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/4] 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 94b82107..29522022 100644 --- a/src/con_duct/cli.py +++ b/src/con_duct/cli.py @@ -454,14 +454,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 e364fddbbe161907163e4d7e8e340d1dbb0e1898 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/4] 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]