`sky status -o json` writes prose to stdout, so the output is not parseable
-o json (added in #8784) prints a human-readable, ANSI-coloured line to stdout ahead of the JSON payload, so json.loads(stdout) fails. stderr is empty.
$ sky status -o json definitely-not-a-cluster-xyz > out.txt 2> err.txt ; echo "exit=$?"
exit=0
$ cat -A out.txt
Cluster(s) not found: ^[[1mdefinitely-not-a-cluster-xyz^[[0m.$
[]$
$ wc -c err.txt
0 err.txt
$ python -c "import json; json.loads(open('out.txt').read())"
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)Note the ^[[1m / ^[[0m escapes on the machine-readable stream.
Cause
Two individually reasonable decisions meet:
sky/backends/backend_utils.py:4078reports the miss through the ordinary logger. It's a library function with no knowledge of the CLI's output mode:
logger.info(f'Cluster(s) not found: {bright}{clusters_str}{reset}.')sky/sky_logging.py:112sends that logger to stdout:
_default_handler = EnvAwareHandler(sys.stdout)So the exposure is general, not specific to this message: any logger.info reached on the way to the answer lands in the payload stream. "Cluster(s) not found" is just the easiest one to trigger.
What already works
Exit codes are a correct discriminator, so the only thing missing is a clean stdout:
| scenario | exit | stdout | stderr |
|---|---|---|---|
| cluster genuinely absent | 0 |
prose + [] |
empty |
| query failed (API server unreachable) | 1 |
empty | traceback |
(second row verified with SKYPILOT_API_SERVER_ENDPOINT=http://127.0.0.1:9)
That distinction is what a programmatic consumer needs and can't currently reach: without parseable JSON the remaining option is parsing the human table, which cannot express the difference between absent and unreadable — both render as "no row". We lost a long-running job to exactly that conflation. The tooling bug there was ours and is fixed on our side, but -o json is the mechanism that exists to prevent it.
Question before sending a PR
Two approaches, and I'd rather not guess:
sky_logging.silent()around the machine-readable path. Covers the general case with no new machinery, is already thread-local, and preserves ERROR. Suppresses rather than redirects, so a human running-o jsonin a terminal loses the message.- Route the handler to stderr under
-o json. Nicer — the message is genuinely useful and stderr is where it belongs — but_default_handleris module-global, so doing it per-command is a larger change.
Guarding the single logger.info would close the reported case and leave the general one open, so I'd rather not do that.
Happy to send a PR for either. Which would you prefer?
Environment
- upstream
master@294124802,sky.__version__ == 1.0.0-dev0 - Python 3.12, Linux 6.8
- infra-independent: the message is emitted before any provider is consulted
Source: skypilot-org/skypilot