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 @@ -464,7 +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="Sort results by one or more fields. See --fields for available choices.",
)
return parser

Expand Down
29 changes: 29 additions & 0 deletions src/con_duct/ls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -231,6 +244,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,
_make_sort_value(x[0].get(k)),
)
for k in sort_by
),
)
Comment on lines +248 to +260
]

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

if args.reverse:
Expand Down
155 changes: 155 additions & 0 deletions test/test_ls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -223,6 +224,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 +257,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 +386,161 @@ 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])

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 add also parametrization of sort_by for each possible one as in LS_FIELD_CHOICES, and ensure that it works correctly

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.

Done. Added test_ls_sort_by_each_field parametrized over all 31 LS_FIELD_CHOICES. Each case creates 3 runs with deliberately non-sorted values for the sort field, then asserts the output is in the correct sorted order.

Also fixed a pre-existing bug in the sort key: fields that hold a list or dict (e.g. gpu) would raise TypeError during comparison. The new _make_sort_value helper serialises those to a JSON string before sorting, giving a stable deterministic order without crashing.

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 +398 to +432


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]


@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]}'"
)