Skip to content

Commit b60e819

Browse files
Simplify CLI stdin implementation
Update the CLI tests to cover reading a tree sequence from stdin when the positional path argument is omitted, replacing the stale tests written for the earlier '-' convention. Adds in-process equivalence tests across all loading subcommands, stdin error-path tests, and end-to-end subprocess tests that exercise a real non-seekable pipe. Also removes a dead comment in cli.py and updates the changelog to describe the omit-argument behaviour.
1 parent 26c346d commit b60e819

3 files changed

Lines changed: 163 additions & 25 deletions

File tree

python/CHANGELOG.rst

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44

55
**Features**
66

7-
- CLI commands that load a tree sequence now accept ``-`` as the input path to
8-
read from stdin. (:issue:`3468`)
7+
- CLI commands that load a tree sequence now read from stdin when the input
8+
path argument is omitted. (:user:`chris-a-talbot`, :user:`jeromekelleher`,
9+
:issue:`3468`, :pr:`3469`)
910

1011
--------------------
1112
[1.0.3] - 2026-05-14

python/tests/test_cli.py

Lines changed: 153 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
import io
2828
import os
29+
import subprocess
2930
import sys
3031
import tempfile
3132
import unittest
@@ -73,11 +74,6 @@ def capture_output(func, *args, **kwargs):
7374
return stdout_output, stderr_output
7475

7576

76-
class MockStdIn:
77-
def __init__(self, buffer):
78-
self.buffer = buffer
79-
80-
8177
class TestCli(unittest.TestCase):
8278
"""
8379
Superclass of tests for the CLI needing temp files.
@@ -315,15 +311,28 @@ def test_vcf_allow_position_zero(self, flags, expected):
315311
assert args.tree_sequence == tree_sequence
316312
assert args.allow_position_zero == expected
317313

318-
def test_vcf_stdin_file(self):
319-
parser = cli.get_tskit_parser()
320-
args = parser.parse_args(["vcf", "-"])
321-
assert args.tree_sequence == "-"
322-
323-
def test_vcf_requires_tree_sequence(self):
314+
@pytest.mark.parametrize(
315+
"cmd",
316+
[
317+
"info",
318+
"trees",
319+
"vcf",
320+
"nodes",
321+
"edges",
322+
"sites",
323+
"mutations",
324+
"migrations",
325+
"individuals",
326+
"populations",
327+
"provenances",
328+
],
329+
)
330+
def test_tree_sequence_argument_optional(self, cmd):
331+
# Omitting the positional argument selects stdin (tree_sequence is None);
332+
# providing a path stores the path string.
324333
parser = cli.get_tskit_parser()
325-
with pytest.raises(SystemExit):
326-
parser.parse_args(["vcf"])
334+
assert parser.parse_args([cmd]).tree_sequence is None
335+
assert parser.parse_args([cmd, "test.trees"]).tree_sequence == "test.trees"
327336

328337
def test_info_default_values(self):
329338
parser = cli.get_tskit_parser()
@@ -576,9 +585,12 @@ def test_vcf(self):
576585
self.verify_vcf(stdout)
577586

578587
def test_vcf_stdin(self):
588+
# Omitting the path argument reads the tree sequence from stdin. The
589+
# low-level loader requires a real file descriptor, so sys.stdin must be
590+
# patched with an actual open binary file (not e.g. an io.BytesIO).
579591
with open(self._tree_sequence_file, "rb") as f:
580-
with mock.patch("sys.stdin", MockStdIn(f)):
581-
stdout, stderr = capture_output(cli.tskit_main, ["vcf", "-0", "-"])
592+
with mock.patch("sys.stdin", f):
593+
stdout, stderr = capture_output(cli.tskit_main, ["vcf", "-0"])
582594
assert len(stderr) == 0
583595
self.verify_vcf(stdout)
584596

@@ -664,3 +676,129 @@ def test_migrations(self):
664676

665677
def test_provenances(self):
666678
self.verify("provenances")
679+
680+
681+
@pytest.fixture(scope="module")
682+
def treeseq_file(tmp_path_factory):
683+
"""
684+
A tree sequence dumped to file, containing migrations, mutations and
685+
individuals so that every loading subcommand has something to output.
686+
"""
687+
ts = msprime.simulate(
688+
length=1,
689+
recombination_rate=2,
690+
mutation_rate=2,
691+
random_seed=1,
692+
migration_matrix=[[0, 1], [1, 0]],
693+
population_configurations=[msprime.PopulationConfiguration(5) for _ in range(2)],
694+
record_migrations=True,
695+
)
696+
assert ts.num_migrations > 0
697+
ts = tsutil.insert_random_ploidy_individuals(ts, samples_only=True)
698+
path = tmp_path_factory.mktemp("tsk_cli_stdin") / "stdin.trees"
699+
ts.dump(path)
700+
return str(path)
701+
702+
703+
# The loading subcommands and any extra flags they need to produce output.
704+
STDIN_SUBCOMMANDS = [
705+
["info"],
706+
["trees"],
707+
["vcf", "-0"],
708+
["nodes"],
709+
["edges"],
710+
["sites"],
711+
["mutations"],
712+
["migrations"],
713+
["individuals"],
714+
["populations"],
715+
["provenances"],
716+
]
717+
718+
719+
class TestStdin:
720+
"""
721+
Tests that reading from stdin (omitting the path argument) produces the same
722+
output as loading from a file, for every loading subcommand.
723+
"""
724+
725+
@pytest.mark.parametrize("subcommand", STDIN_SUBCOMMANDS)
726+
def test_stdin_matches_file(self, treeseq_file, subcommand):
727+
file_stdout, file_stderr = capture_output(
728+
cli.tskit_main, [*subcommand, treeseq_file]
729+
)
730+
with open(treeseq_file, "rb") as f:
731+
with mock.patch("sys.stdin", f):
732+
stdin_stdout, stdin_stderr = capture_output(cli.tskit_main, subcommand)
733+
assert file_stderr == ""
734+
assert stdin_stderr == ""
735+
assert len(file_stdout) > 0
736+
assert stdin_stdout == file_stdout
737+
738+
739+
class TestStdinErrors:
740+
"""
741+
Tests that errors loading from stdin are reported cleanly.
742+
"""
743+
744+
def run_info_stdin(self, path):
745+
with mock.patch("sys.exit", side_effect=TestException) as mocked_exit:
746+
with open(path, "rb") as f:
747+
with mock.patch("sys.stdin", f):
748+
with pytest.raises(TestException):
749+
capture_output(cli.tskit_main, ["info"])
750+
return mocked_exit.call_args[0][0]
751+
752+
def test_empty_stdin(self, tmp_path):
753+
path = tmp_path / "empty.trees"
754+
path.write_bytes(b"")
755+
assert self.run_info_stdin(path) == "Load error: End of file"
756+
757+
def test_garbage_stdin(self, tmp_path):
758+
path = tmp_path / "garbage.trees"
759+
path.write_bytes(b"not a tree sequence at all")
760+
message = self.run_info_stdin(path)
761+
assert message.startswith("Load error: File not in kastore format")
762+
763+
def test_truncated_stdin(self, tmp_path, treeseq_file):
764+
path = tmp_path / "truncated.trees"
765+
with open(treeseq_file, "rb") as f:
766+
path.write_bytes(f.read(100))
767+
message = self.run_info_stdin(path)
768+
assert message.startswith("Load error: File not in kastore format")
769+
770+
771+
class TestStdinSubprocess:
772+
"""
773+
End-to-end tests that feed the tree sequence through a real OS pipe. Unlike
774+
the in-process tests (which mock sys.stdin with a seekable file), these
775+
exercise the genuine non-seekable stdin path.
776+
"""
777+
778+
def run_cli(self, args, input_bytes):
779+
return subprocess.run(
780+
[sys.executable, "-m", "tskit", *args],
781+
input=input_bytes,
782+
capture_output=True,
783+
)
784+
785+
@pytest.mark.parametrize("subcommand", [["info"], ["vcf", "-0"], ["nodes"]])
786+
def test_stdin_pipe_matches_file(self, treeseq_file, subcommand):
787+
with open(treeseq_file, "rb") as f:
788+
ts_bytes = f.read()
789+
stdin_result = self.run_cli(subcommand, ts_bytes)
790+
file_result = self.run_cli([*subcommand, treeseq_file], b"")
791+
assert stdin_result.returncode == 0
792+
assert stdin_result.stderr == b""
793+
assert len(stdin_result.stdout) > 0
794+
assert stdin_result.stdout == file_result.stdout
795+
796+
def test_empty_pipe(self):
797+
result = self.run_cli(["info"], b"")
798+
assert result.returncode != 0
799+
assert b"End of file" in result.stderr
800+
801+
def test_garbage_pipe(self):
802+
result = self.run_cli(["info"], b"not a tree sequence")
803+
assert result.returncode != 0
804+
assert b"not in kastore format" in result.stderr

python/tskit/cli.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#
22
# MIT License
33
#
4-
# Copyright (c) 2018-2025 Tskit Developers
4+
# Copyright (c) 2018-2026 Tskit Developers
55
# Copyright (c) 2015-2018 University of Oxford
66
#
77
# Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -45,15 +45,12 @@ def sys_exit(message):
4545

4646

4747
def load_tree_sequence(path):
48-
if path in [None, "-"]:
49-
path = getattr(sys.stdin, "buffer", sys.stdin)
48+
if path is None:
49+
path = sys.stdin
5050
try:
5151
return tskit.load(path)
5252
except (OSError, EOFError, tskit.FileFormatError) as e:
53-
message = str(e)
54-
if isinstance(e, EOFError) and len(message) == 0:
55-
message = "End of file"
56-
sys_exit(f"Load error: {message}")
53+
sys_exit(f"Load error: {e}")
5754

5855

5956
def run_info(args):
@@ -141,7 +138,9 @@ def run_vcf(args):
141138
def add_tree_sequence_argument(parser):
142139
parser.add_argument(
143140
"tree_sequence",
144-
help="The tskit tree sequence file, or '-' for stdin",
141+
help="The tskit tree sequence file. If not provided, read from stdin.",
142+
default=None,
143+
nargs="?",
145144
)
146145

147146

0 commit comments

Comments
 (0)