Skip to content

Commit

Permalink
enh: Add con-duct ls
Browse files Browse the repository at this point in the history
Fixes: #185

Note: Ignore pyout typing; +1 upstream, but fix too big
  pyout/pyout#142
  pyout/pyout#151
  • Loading branch information
asmacdo committed Feb 6, 2025
1 parent 50a711e commit 896972a
Show file tree
Hide file tree
Showing 6 changed files with 223 additions and 7 deletions.
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,12 +139,14 @@ usage: con-duct <command> [options]
A suite of commands to manage or manipulate con-duct logs.
positional arguments:
{pp,plot} Available subcommands
pp Pretty print a JSON log.
plot Plot resource usage for an execution.
{pp,plot,ls} Available subcommands
pp Pretty print a JSON log.
plot Plot resource usage for an execution.
ls Print execution information for all runs matching
DUCT_OUTPUT_PREFIX.
options:
-h, --help show this help message and exit
-h, --help show this help message and exit
```
<!-- END EXTRAS HELP -->
2 changes: 2 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ where = src
[options.extras_require]
all =
matplotlib
PyYAML
pyout


[options.entry_points]
Expand Down
7 changes: 4 additions & 3 deletions src/con_duct/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
lgr = logging.getLogger("con-duct")
DEFAULT_LOG_LEVEL = os.environ.get("DUCT_LOG_LEVEL", "INFO").upper()

DUCT_OUTPUT_PREFIX = os.getenv(
"DUCT_OUTPUT_PREFIX", ".duct/logs/{datetime_filesafe}-{pid}_"
)
ENV_PREFIXES = ("PBS_", "SLURM_", "OSG")
SUFFIXES = {
"stdout": "stdout",
Expand Down Expand Up @@ -712,9 +715,7 @@ def from_argv(
"-p",
"--output-prefix",
type=str,
default=os.getenv(
"DUCT_OUTPUT_PREFIX", ".duct/logs/{datetime_filesafe}-{pid}_"
),
default=DUCT_OUTPUT_PREFIX,
help="File string format to be used as a prefix for the files -- the captured "
"stdout and stderr and the resource usage logs. The understood variables are "
"{datetime}, {datetime_filesafe}, and {pid}. "
Expand Down
167 changes: 167 additions & 0 deletions src/con_duct/suite/ls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import argparse
from collections import OrderedDict
import glob
import json
import logging
from typing import List
from packaging.version import Version

try:
import pyout # type: ignore
except ImportError:
pyout = None

Check warning on line 12 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L11-L12

Added lines #L11 - L12 were not covered by tests
import yaml
from con_duct.__main__ import SummaryFormatter

lgr = logging.getLogger(__name__)


VALUE_TRANSFORMATION_MAP = {
"exit_code": "{value!E}",
"wall_clock_time": "{value:.3f} sec",
"peak_rss": "{value!S}",
"memory_total": "{value!S}",
"average_rss": "{value!S}",
"peak_vsz": "{value!S}",
"average_vsz": "{value!S}",
"peak_pmem": "{value:.2f!N}%",
"average_pmem": "{value:.2f!N}%",
"peak_pcpu": "{value:.2f!N}%",
"average_pcpu": "{value:.2f!N}%",
"start_time": "{value:.2f!N}",
"end_time": "{value:.2f!N}",
}
NON_TRANSFORMED_FIELDS = [
"hostname",
"uid",
"user",
"gpu",
"duct_version",
"schema_version",
"command",
"prefix",
"num_samples",
"num_reports",
"stderr",
"usage",
"info",
"prefix",
]
LS_FIELD_CHOICES = list(VALUE_TRANSFORMATION_MAP.keys()) + NON_TRANSFORMED_FIELDS
MINIMUM_SCHEMA_VERSION = "0.2.0"


def load_duct_runs(info_files: List[str]) -> List[dict]:
loaded = []

Check warning on line 55 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L55

Added line #L55 was not covered by tests
for info_file in info_files:
with open(info_file) as file:
try:
this = json.load(file)

Check warning on line 59 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L57-L59

Added lines #L57 - L59 were not covered by tests
# this["prefix"] is the path at execution time, could have moved
this["prefix"] = info_file.split("info.json")[0]

Check warning on line 61 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L61

Added line #L61 was not covered by tests
if Version(this["schema_version"]) >= Version(MINIMUM_SCHEMA_VERSION):
loaded.append(this)

Check warning on line 63 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L63

Added line #L63 was not covered by tests
else:
# TODO lower log level once --log-level is respected
lgr.warning(

Check warning on line 66 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L66

Added line #L66 was not covered by tests
f"Skipping {this['prefix']}, schema version {this['schema_version']} "
f"is below minimum schema version {MINIMUM_SCHEMA_VERSION}."
)
except Exception as exc:
lgr.warning("Failed to load file %s: %s", file, exc)
return loaded

Check warning on line 72 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L70-L72

Added lines #L70 - L72 were not covered by tests


def process_run_data(
run_data_list: List[str], fields: List[str], formatter
) -> List[OrderedDict]:
output_rows = []

Check warning on line 78 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L78

Added line #L78 was not covered by tests
for row in run_data_list:
flattened = _flatten_dict(row)
try:
restricted = _restrict_row(fields, flattened)
except KeyError:
lgr.warning(

Check warning on line 84 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L80-L84

Added lines #L80 - L84 were not covered by tests
"Failed to pick fields of interest from a record, skipping. Record was: %s",
list(flattened),
)
continue
formatted = _format_row(restricted, formatter)
output_rows.append(formatted)
return output_rows

Check warning on line 91 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L88-L91

Added lines #L88 - L91 were not covered by tests


def _flatten_dict(d):
items = []

Check warning on line 95 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L95

Added line #L95 was not covered by tests
for k, v in d.items():
if isinstance(v, dict):
items.extend(_flatten_dict(v).items())

Check warning on line 98 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L98

Added line #L98 was not covered by tests
else:
items.append((k, v))
return dict(items)

Check warning on line 101 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L100-L101

Added lines #L100 - L101 were not covered by tests


def _restrict_row(field_list, row):
restricted = OrderedDict()

Check warning on line 105 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L105

Added line #L105 was not covered by tests
# prefix is the "primary key", its the only field guaranteed to be unique.
restricted["prefix"] = row["prefix"]

Check warning on line 107 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L107

Added line #L107 was not covered by tests
for field in field_list:
if field != "prefix" and field in row:
restricted[field.split(".")[-1]] = row[field]
return restricted

Check warning on line 111 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L110-L111

Added lines #L110 - L111 were not covered by tests


def _format_row(row, formatter):
transformed = OrderedDict()

Check warning on line 115 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L115

Added line #L115 was not covered by tests
for col, value in row.items():
if transformation := VALUE_TRANSFORMATION_MAP.get(col):
value = formatter.format(transformation, value=value)
transformed[col] = value
return transformed

Check warning on line 120 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L118-L120

Added lines #L118 - L120 were not covered by tests


def pyout_ls(run_data_list):
# Generate Tabular table to output
with pyout.Tabular(

Check warning on line 125 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L125

Added line #L125 was not covered by tests
style=dict(
header_=dict(bold=True, transform=str.upper),
),
mode="final",
) as table:
for row in run_data_list:
table(row)

Check warning on line 132 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L132

Added line #L132 was not covered by tests


def ls(args: argparse.Namespace) -> int:
info_files = []

Check warning on line 136 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L136

Added line #L136 was not covered by tests
for each in args.pattern:
matches = glob.glob(f"{each}_info.json")
info_files.extend(matches)
run_data_raw = load_duct_runs(info_files)
formatter = SummaryFormatter(enable_colors=args.colors)
output_rows = process_run_data(run_data_raw, args.fields, formatter)

Check warning on line 142 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L138-L142

Added lines #L138 - L142 were not covered by tests

if args.format == "auto":
args.format = "summaries" if pyout is None else "pyout"

Check warning on line 145 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L145

Added line #L145 was not covered by tests

if args.format == "summaries":
for row in output_rows:
for col, value in row.items():
if not col == "prefix":
col = f"\t{col}"
print(f"{col.replace('_', ' ').title()}: {value}")

Check warning on line 152 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L151-L152

Added lines #L151 - L152 were not covered by tests
elif args.format == "pyout":
if pyout is None:
raise RuntimeError("Install pyout for pyout output")
pyout_ls(output_rows)

Check warning on line 156 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L155-L156

Added lines #L155 - L156 were not covered by tests
elif args.format == "json":
print(json.dumps(output_rows))

Check warning on line 158 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L158

Added line #L158 was not covered by tests
elif args.format == "json_pp":
print(json.dumps(output_rows, indent=True))

Check warning on line 160 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L160

Added line #L160 was not covered by tests
elif args.format == "yaml":
print(yaml.dump(output_rows, default_flow_style=False))

Check warning on line 162 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L162

Added line #L162 was not covered by tests
else:
raise RuntimeError(

Check warning on line 164 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L164

Added line #L164 was not covered by tests
f"Unexpected format encountered: {args.format}. This should have been caught by argparse.",
)
return 0

Check warning on line 167 in src/con_duct/suite/ls.py

View check run for this annotation

Codecov / codecov/patch

src/con_duct/suite/ls.py#L167

Added line #L167 was not covered by tests
43 changes: 43 additions & 0 deletions src/con_duct/suite/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import argparse
import os
import sys
from typing import List, Optional
from con_duct.__main__ import DUCT_OUTPUT_PREFIX
from con_duct.suite.ls import LS_FIELD_CHOICES, ls
from con_duct.suite.plot import matplotlib_plot
from con_duct.suite.pprint_json import pprint_json

Expand Down Expand Up @@ -46,6 +49,46 @@ def main(argv: Optional[List[str]] = None) -> None:
# )
parser_plot.set_defaults(func=matplotlib_plot)

parser_ls = subparsers.add_parser(
"ls",
help="Print execution information for all runs matching DUCT_OUTPUT_PREFIX.",
)
parser_ls.add_argument(
"-f",
"--format",
choices=("auto", "pyout", "summaries", "json", "json_pp", "yaml"),
default="auto",
help="Output format. 'auto' chooses 'pyout' if pyout library is installed, 'summaries' otherwise.",
)
parser_ls.add_argument(
"-F",
"--fields",
nargs="+",
metavar="FIELD",
help=f"List of fields to show. Prefix is always included implicitly as the first field. "
f"Available choices: {', '.join(LS_FIELD_CHOICES)}.",
choices=LS_FIELD_CHOICES,
default=[
"command",
"exit_code",
"wall_clock_time",
"peak_rss",
],
)
parser_ls.add_argument(
"--colors",
action="store_true",
default=os.getenv("DUCT_COLORS", False),
help="Use colors in duct output.",
)
parser_ls.add_argument(
"pattern",
nargs="*",
default=[f"{DUCT_OUTPUT_PREFIX[:DUCT_OUTPUT_PREFIX.index('{')]}*"],
help="Path(s) to list, supports globbing (defaults to the non-dynamic portion of DUCT_OUTPUT_PREFIX",
)
parser_ls.set_defaults(func=ls)

args = parser.parse_args(argv)

if args.command is None:
Expand Down
1 change: 1 addition & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ commands =
deps =
mypy
data-science-types # TODO replace archived, https://github.com/wearepal/data-science-types
types-PyYAML
{[testenv]deps}
commands =
mypy src test
Expand Down

0 comments on commit 896972a

Please sign in to comment.