Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/con_duct/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +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="Sort results by one or more fields. See --fields for available choices.",
)
return parser

Expand Down
16 changes: 16 additions & 0 deletions src/con_duct/ls.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,22 @@ 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)

if sort_by := getattr(args, "sort_by", None):
run_data_raw = [
item
for _, item in sorted(
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 "",
)
for k in sort_by
),
)
Comment on lines +235 to +247
]

output_rows = process_run_data(run_data_raw, args.fields, formatter)

Comment on lines +234 to 251

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this does look much cleaner but would it still have all the fields at that point or only what is to be displayed etc? (not looking at the code ATM)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot this does look much cleaner but would it still have all the fields at that point or only what is to be displayed etc? (not looking at the code ATM -- do code analysis and try to sort by fields which are not to be displayed... add test for that)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — the sort operates on run_data_raw (full raw dicts) before process_run_data restricts to --fields, so all fields are available for sorting regardless of what's displayed. Added test_ls_sort_by_non_displayed_field (commit test: add test for sorting by non-displayed field) which sorts by command while fields=["prefix"] omits it from output, and verifies the output order matches sorted command order.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot please clarify if this would work given the question I gave above

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it works for non-displayed fields. The sort (lines 235–248) runs on run_data_raw — the full unfiltered dicts from disk — by temporarily flattening them with _flatten_dict. Field restriction to --fields only happens inside process_run_data on line 250, after sorting is complete. So every field in the JSON is available as a sort key regardless of what is selected for display. test_ls_sort_by_non_displayed_field demonstrates this: it sorts by command while fields=["prefix"] omits command from the output, and the test verifies the output is in command-sorted order.

if args.reverse:
Expand Down
84 changes: 84 additions & 0 deletions test/test_ls.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ def _run_ls(
format=fmt,
func=ls,
reverse=False,
sort_by=None,
)
buf = StringIO()
with contextlib.redirect_stdout(buf):
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -383,9 +385,91 @@ 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))


@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)
Comment on lines +397 to +431


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]
Loading