|
26 | 26 |
|
27 | 27 | import io |
28 | 28 | import os |
| 29 | +import subprocess |
29 | 30 | import sys |
30 | 31 | import tempfile |
31 | 32 | import unittest |
@@ -73,11 +74,6 @@ def capture_output(func, *args, **kwargs): |
73 | 74 | return stdout_output, stderr_output |
74 | 75 |
|
75 | 76 |
|
76 | | -class MockStdIn: |
77 | | - def __init__(self, buffer): |
78 | | - self.buffer = buffer |
79 | | - |
80 | | - |
81 | 77 | class TestCli(unittest.TestCase): |
82 | 78 | """ |
83 | 79 | Superclass of tests for the CLI needing temp files. |
@@ -315,15 +311,28 @@ def test_vcf_allow_position_zero(self, flags, expected): |
315 | 311 | assert args.tree_sequence == tree_sequence |
316 | 312 | assert args.allow_position_zero == expected |
317 | 313 |
|
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. |
324 | 333 | 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" |
327 | 336 |
|
328 | 337 | def test_info_default_values(self): |
329 | 338 | parser = cli.get_tskit_parser() |
@@ -576,9 +585,12 @@ def test_vcf(self): |
576 | 585 | self.verify_vcf(stdout) |
577 | 586 |
|
578 | 587 | 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). |
579 | 591 | 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"]) |
582 | 594 | assert len(stderr) == 0 |
583 | 595 | self.verify_vcf(stdout) |
584 | 596 |
|
@@ -664,3 +676,129 @@ def test_migrations(self): |
664 | 676 |
|
665 | 677 | def test_provenances(self): |
666 | 678 | 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 |
0 commit comments