mlperf-sysinfo 1.0.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ # Copyright 2026 MLCommons contributors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """mlperf-sysinfo -- capture MLPerf system descriptions.
4
+
5
+ One config format, one command line, and a profile per working group that
6
+ decides what is required and what the output looks like.
7
+
8
+ Embedding it in another tool:
9
+
10
+ from mlperf_sysinfo import load_config, capture
11
+
12
+ config = load_config("sysinfo.yaml")
13
+ result = capture(config)
14
+
15
+ result.output_path # where the file landed
16
+ result.nodes # per-node status, so callers can report properly
17
+ result.complete # False if any node did not answer
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ # Read from the installed distribution so this can never drift from
23
+ # pyproject.toml -- the release workflow checks the tag against pyproject,
24
+ # and a hand-maintained copy here would not be caught by that check.
25
+ from importlib.metadata import PackageNotFoundError # noqa: E402
26
+ from importlib.metadata import version as _version # noqa: E402
27
+
28
+ try:
29
+ __version__ = _version("mlperf-sysinfo")
30
+ except PackageNotFoundError: # running from a source tree, not installed
31
+ __version__ = "0.0.0+unknown"
32
+
33
+ from .config import SysinfoConfig, load_config # noqa: E402
34
+ from .errors import ( # noqa: E402
35
+ CaptureError,
36
+ CheckFailed,
37
+ ConfigError,
38
+ DependencyMissing,
39
+ ProfileError,
40
+ SysinfoError,
41
+ )
42
+
43
+ __all__ = [
44
+ "__version__",
45
+ "SysinfoConfig",
46
+ "load_config",
47
+ "capture",
48
+ "check",
49
+ "SysinfoError",
50
+ "ConfigError",
51
+ "ProfileError",
52
+ "CheckFailed",
53
+ "CaptureError",
54
+ "DependencyMissing",
55
+ ]
56
+
57
+
58
+ def capture(config, profile=None, **kwargs):
59
+ """Check, collect, shape, write. See :mod:`mlperf_sysinfo.collector`."""
60
+ from .collector import capture as _capture
61
+
62
+ return _capture(config, profile, **kwargs)
63
+
64
+
65
+ def check(config, profile=None, **kwargs):
66
+ """Run pre-flight only. See :mod:`mlperf_sysinfo.preflight`."""
67
+ from .preflight import run_check
68
+ from .profiles import load as load_profile
69
+
70
+ if profile is None:
71
+ relative_to = config.source_path.parent if config.source_path else None
72
+ profile = load_profile(config.profile, relative_to=relative_to)
73
+ return run_check(config, profile, **kwargs)
mlperf_sysinfo/cli.py ADDED
@@ -0,0 +1,411 @@
1
+ # Copyright 2026 MLCommons contributors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The command line.
4
+
5
+ Five commands: init, check, capture, show, validate. Check is not optional --
6
+ capture always runs it first and there is no flag to skip it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import shutil
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Annotated
16
+
17
+ import cyclopts
18
+ from cyclopts.exceptions import CycloptsError
19
+
20
+ from . import __version__, ui
21
+ from .collector import capture as run_capture
22
+ from .config import load_config
23
+ from .errors import CheckFailed, SysinfoError
24
+ from .preflight import CheckReport, run_check
25
+ from .profiles import available as available_profiles
26
+ from .profiles import load as load_profile
27
+ from .report import summarise, validate
28
+
29
+ _TEMPLATE_DIR = Path(__file__).parent / "templates"
30
+
31
+ EXIT_OK = 0
32
+ EXIT_PROBLEMS = 1
33
+ EXIT_ERROR = 2
34
+
35
+ app = cyclopts.App(
36
+ name="mlperf-sysinfo",
37
+ version=__version__,
38
+ help="Capture MLPerf system descriptions. One config, one CLI, a profile per working group.",
39
+ )
40
+
41
+ ConfigOpt = Annotated[
42
+ Path, cyclopts.Parameter(name=["--config", "-c"], help="Path to the config file.")
43
+ ]
44
+
45
+
46
+ def _setup_logging(verbose: bool) -> None:
47
+ logging.basicConfig(
48
+ level=logging.DEBUG if verbose else logging.WARNING,
49
+ format="%(levelname)s %(message)s",
50
+ )
51
+
52
+
53
+ def _load(config_path: Path):
54
+ """Load config and its profile together -- they are always needed as a pair."""
55
+ config = load_config(config_path)
56
+ profile = load_profile(config.profile, relative_to=Path(config_path).resolve().parent)
57
+ return config, profile
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # rendering
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ def _render_check(report: CheckReport, *, out_file: Path) -> None:
66
+ config, profile = report.config, report.profile
67
+
68
+ ui.blank()
69
+ ui.kv("profile", f"{ui.bold(profile.name)} {ui.dim(f'(v{profile.round} rules)')}")
70
+ ui.kv("output", str(out_file))
71
+
72
+ all_targets = [str(t) for t in config.all_targets]
73
+ labels = [n.label for n in report.nodes] + all_targets + ["this machine"]
74
+ if report.endpoint:
75
+ labels.append(report.endpoint.label)
76
+ if report.serving_log:
77
+ labels.append(report.serving_log.label)
78
+ width = max((len(x) for x in labels), default=12) + 2
79
+
80
+ ui.heading("nodes")
81
+ if not report.network_checked:
82
+ for raw in all_targets:
83
+ ui.row(ui.SKIP, raw, ui.dim("not checked"), "--offline was passed", width)
84
+ for node in report.nodes:
85
+ if node.reachable:
86
+ ui.row(ui.OK, node.label, ui.green("reachable"), node.detail, width)
87
+ else:
88
+ ui.row(ui.BAD, node.label, ui.red("unreachable"), node.detail, width)
89
+ if config.nodes.include_local:
90
+ ui.row(ui.OK, "this machine", ui.green("included"), "", width)
91
+ else:
92
+ ui.row(ui.SKIP, "this machine", ui.dim("excluded"), "nodes.include_local is false", width)
93
+
94
+ if report.endpoint or report.serving_log:
95
+ ui.heading("serving")
96
+ if report.endpoint:
97
+ e = report.endpoint
98
+ ui.row(
99
+ ui.OK if e.ok else ui.WARN,
100
+ e.label,
101
+ ui.green("reachable") if e.ok else ui.yellow("no answer"),
102
+ e.detail,
103
+ width,
104
+ )
105
+ if report.serving_log:
106
+ s = report.serving_log
107
+ ui.row(
108
+ ui.OK if s.ok else ui.WARN,
109
+ s.label,
110
+ ui.green("present") if s.ok else ui.yellow("missing"),
111
+ s.detail,
112
+ width,
113
+ )
114
+
115
+ # The field block has its own column width -- dotted paths are much longer
116
+ # than host names and would otherwise ragged-edge against them.
117
+ field_labels = [
118
+ p
119
+ for p, _ in (
120
+ report.missing_required
121
+ + report.placeholder_required
122
+ + report.missing_recommended
123
+ + report.placeholder_recommended
124
+ + report.placeholder_other
125
+ )
126
+ ] + report.unresolved_env
127
+ fwidth = max((len(x) for x in field_labels), default=20) + 2
128
+
129
+ ui.heading(f"required by profile '{profile.name}'")
130
+ if report.satisfied_count:
131
+ ui.row(ui.OK, f"{report.satisfied_count} fields set", "", "", fwidth)
132
+ for path, why in report.missing_required:
133
+ ui.row(ui.BAD, path, ui.red("missing"), why, fwidth)
134
+ for path, why in report.placeholder_required:
135
+ ui.row(ui.BAD, path, ui.red("placeholder"), f"still the starter value -- {why}", fwidth)
136
+ for ref in report.unresolved_env:
137
+ ui.row(ui.BAD, ref, ui.red("unset"), "not found in the environment", fwidth)
138
+
139
+ if report.placeholder_other:
140
+ ui.heading("still starter text")
141
+ for path, value in report.placeholder_other:
142
+ ui.row(ui.BAD, path, ui.red("placeholder"), repr(value), fwidth)
143
+
144
+ if report.missing_recommended or report.placeholder_recommended:
145
+ ui.heading("worth filling in")
146
+ for path, why in report.missing_recommended:
147
+ ui.row(ui.WARN, path, ui.yellow("empty"), why, fwidth)
148
+ for path, why in report.placeholder_recommended:
149
+ ui.row(
150
+ ui.WARN, path, ui.yellow("placeholder"), f"still the starter value -- {why}", fwidth
151
+ )
152
+
153
+ ui.blank()
154
+ if report.ok:
155
+ if report.network_checked:
156
+ print(f" {ui.green('Ready to capture.')}")
157
+ else:
158
+ print(f" {ui.green('Config is valid.')}")
159
+ ui.hint("Nothing was reached -- run without --offline before capturing.")
160
+ return
161
+
162
+ count = report.problem_count
163
+ noun = "problem" if count == 1 else "problems"
164
+ print(f" {ui.red(f'{count} {noun}.')}")
165
+ if report.has_config_problems:
166
+ ui.hint("Fill in the missing fields and run check again.")
167
+ if report.has_reach_problems:
168
+ ui.hint(
169
+ "Fix the unreachable nodes, or run capture --allow-partial "
170
+ "to proceed without them."
171
+ )
172
+
173
+
174
+ _SYMBOLS = {"ok": ui.OK, "bad": ui.BAD, "warn": ui.WARN, "skip": ui.SKIP}
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # commands
179
+ # ---------------------------------------------------------------------------
180
+
181
+
182
+ @app.command
183
+ def init(
184
+ profile: str = "endpoints",
185
+ *,
186
+ path: Annotated[
187
+ Path,
188
+ cyclopts.Parameter(
189
+ name=["--path"], help="Where to write the config. Defaults to sysinfo.yaml in the current directory."
190
+ ),
191
+ ] = Path("sysinfo.yaml"),
192
+ force: Annotated[
193
+ bool, cyclopts.Parameter(help="Overwrite the file if it already exists.")
194
+ ] = False,
195
+ ) -> int:
196
+ """Write a starter config containing only the fields your profile needs."""
197
+ template = _TEMPLATE_DIR / f"{profile}.yaml"
198
+ if not template.exists():
199
+ ui.error(
200
+ f"no starter config for profile {profile!r}. "
201
+ f"Built-in profiles: {', '.join(available_profiles())}"
202
+ )
203
+ return EXIT_ERROR
204
+ if path.exists() and not force:
205
+ ui.error(f"{path} already exists. Pass --force to overwrite it.")
206
+ return EXIT_ERROR
207
+
208
+ path.parent.mkdir(parents=True, exist_ok=True)
209
+ shutil.copyfile(template, path)
210
+
211
+ loaded = load_profile(profile)
212
+ ui.blank()
213
+ print(f" {ui.green('Written')} template {path.name} to path: {path.resolve()}")
214
+ ui.blank()
215
+ ui.hint(f"profile: {loaded.name}")
216
+ ui.blank()
217
+ ui.hint(f"Edit the template {path.name} before running the actual capture command.")
218
+ return EXIT_OK
219
+
220
+
221
+ @app.command
222
+ def check(
223
+ *,
224
+ config: ConfigOpt,
225
+ offline: Annotated[
226
+ bool,
227
+ cyclopts.Parameter(help="Validate the config only; do not touch the network."),
228
+ ] = False,
229
+ verbose: Annotated[bool, cyclopts.Parameter(name=["--verbose", "-v"])] = False,
230
+ ) -> int:
231
+ """Validate the config and reach every node it names. Nothing is collected."""
232
+ _setup_logging(verbose)
233
+ cfg, profile = _load(config)
234
+ report = run_check(cfg, profile, skip_network=offline)
235
+ out_file = cfg.output_dir / (cfg.output.file or profile.output_file)
236
+ _render_check(report, out_file=out_file)
237
+ return EXIT_OK if report.ok else EXIT_PROBLEMS
238
+
239
+
240
+ @app.command
241
+ def capture(
242
+ *,
243
+ config: ConfigOpt,
244
+ allow_partial: Annotated[
245
+ bool,
246
+ cyclopts.Parameter(
247
+ help="Proceed even if a node is unreachable. The output is marked partial."
248
+ ),
249
+ ] = False,
250
+ run_metadata: Annotated[
251
+ Path | None,
252
+ cyclopts.Parameter(help="A run_metadata file to patch with serving config values."),
253
+ ] = None,
254
+ verbose: Annotated[bool, cyclopts.Parameter(name=["--verbose", "-v"])] = False,
255
+ ) -> int:
256
+ """Run the check, then collect and write the system description."""
257
+ _setup_logging(verbose)
258
+ cfg, profile = _load(config)
259
+
260
+ ui.blank()
261
+
262
+ def progress(kind: str, label: str, detail: str) -> None:
263
+ ui.row(_SYMBOLS.get(kind, " "), label, detail, "", 20)
264
+
265
+ try:
266
+ result = run_capture(
267
+ cfg,
268
+ profile,
269
+ allow_partial=allow_partial,
270
+ run_metadata_path=run_metadata,
271
+ progress=progress,
272
+ verbose=verbose,
273
+ )
274
+ except CheckFailed as e:
275
+ if e.report is not None:
276
+ out_file = cfg.output_dir / (cfg.output.file or profile.output_file)
277
+ _render_check(e.report, out_file=out_file)
278
+ else: # pragma: no cover - defensive
279
+ ui.error(str(e))
280
+ ui.blank()
281
+ ui.error("nothing was collected")
282
+ return EXIT_PROBLEMS
283
+
284
+ ui.blank()
285
+ bits = [f"{result.nodes_collected} node(s)"]
286
+ accel = result.accelerator_total
287
+ if accel:
288
+ bits.append(f"{accel} accelerators")
289
+ bits.append(f"profile {profile.name}")
290
+ print(" " + ui.dim(" - ".join(bits)))
291
+ ui.blank()
292
+
293
+ if result.complete:
294
+ print(f" {ui.green('Written')} {result.output_path}")
295
+ else:
296
+ print(f" {ui.yellow('Written (partial)')} {result.output_path}")
297
+ ui.hint("One or more nodes did not answer. The file records this.")
298
+ for extra in result.extra_files:
299
+ print(f" {ui.dim('also')} {extra}")
300
+ ui.blank()
301
+ ui.hint(f"Next: mlperf-sysinfo show {result.output_path}")
302
+ return EXIT_OK if result.complete else EXIT_PROBLEMS
303
+
304
+
305
+ @app.command
306
+ def show(path: Path) -> int:
307
+ """Print a readable summary of a captured file."""
308
+ s = summarise(path)
309
+
310
+ ui.blank()
311
+ ui.kv("system", ui.bold(s.system_name), 12)
312
+ ui.kv("profile", f"{s.profile} {ui.dim(f'(v{s.profile_round} rules)')}", 12)
313
+ ui.kv("captured", s.captured_at, 12)
314
+ if not s.complete:
315
+ ui.kv("state", ui.yellow("PARTIAL -- not the whole system"), 12)
316
+ if s.system_size:
317
+ ui.kv("size", s.system_size, 12)
318
+ if s.framework:
319
+ ui.kv("serving", s.framework, 12)
320
+
321
+ if s.nodes:
322
+ ui.heading("node types")
323
+ for accel, count, cpu in s.nodes:
324
+ detail = f"on {cpu}" if cpu else ""
325
+ ui.row(ui.dim("·"), f"{count} x {accel}", "", detail, 44)
326
+
327
+ if s.detected:
328
+ ui.heading("detected")
329
+ for label, value in s.detected:
330
+ ui.kv(label, value, 14)
331
+
332
+ if s.supplied:
333
+ ui.heading("from your config")
334
+ for label, value in s.supplied:
335
+ ui.kv(label, value, 14)
336
+
337
+ ui.blank()
338
+ return EXIT_OK
339
+
340
+
341
+ @app.command(name="validate")
342
+ def validate_cmd(
343
+ path: Path,
344
+ *,
345
+ profile: Annotated[
346
+ str | None,
347
+ cyclopts.Parameter(help="Validate against this profile instead of the stamped one."),
348
+ ] = None,
349
+ ) -> int:
350
+ """Check a captured file against a profile before submitting it."""
351
+ report = validate(path, profile_name=profile)
352
+
353
+ ui.blank()
354
+ ui.kv("file", str(report.path), 10)
355
+ ui.kv("profile", f"{report.profile_name} {ui.dim(f'(v{report.profile_round} rules)')}", 10)
356
+
357
+ if report.problems:
358
+ ui.heading("problems")
359
+ for problem in report.problems:
360
+ ui.row(ui.BAD, problem, "", "", 0)
361
+ if report.warnings:
362
+ ui.heading("warnings")
363
+ for warning in report.warnings:
364
+ ui.row(ui.WARN, warning, "", "", 0)
365
+
366
+ ui.blank()
367
+ if report.ok:
368
+ extra = f" {len(report.warnings)} warning(s)." if report.warnings else ""
369
+ print(f" {ui.green('Valid.')} {report.checked} required field(s) present.{extra}")
370
+ return EXIT_OK
371
+ count = len(report.problems)
372
+ print(f" {ui.red(f'{count} problem(s).')} This file is not ready to submit.")
373
+ return EXIT_PROBLEMS
374
+
375
+
376
+ @app.command(name="profiles")
377
+ def profiles_cmd() -> int:
378
+ """List the built-in profiles."""
379
+ ui.blank()
380
+ for name in available_profiles():
381
+ p = load_profile(name)
382
+ print(f" {ui.bold(p.name.ljust(12))} {p.title} {ui.dim(f'round {p.round}')}")
383
+ ui.hint(f" {p.description.strip()}")
384
+ ui.hint(f" {len(p.requires)} required field(s), writes {p.shape} {p.output_file}")
385
+ ui.blank()
386
+ return EXIT_OK
387
+
388
+
389
+ def main() -> None:
390
+ """Entry point.
391
+
392
+ The exit codes are a contract, because ``check`` is meant to be scriptable:
393
+ 0 all good, 1 the run found problems, 2 the command or config was wrong.
394
+ A mistyped flag must not look like a failed check.
395
+ """
396
+ try:
397
+ code = app(exit_on_error=False)
398
+ except SysinfoError as e:
399
+ ui.error(str(e))
400
+ sys.exit(EXIT_ERROR)
401
+ except CycloptsError as e:
402
+ ui.error(str(e))
403
+ sys.exit(EXIT_ERROR)
404
+ except KeyboardInterrupt: # pragma: no cover
405
+ ui.error("interrupted")
406
+ sys.exit(130)
407
+ sys.exit(code if isinstance(code, int) else EXIT_OK)
408
+
409
+
410
+ if __name__ == "__main__": # pragma: no cover
411
+ main()