From db08838b9e40c6b30fae6fce0b464669f36b0af0 Mon Sep 17 00:00:00 2001 From: Olga Botvinnik Date: Sat, 28 May 2022 13:23:33 -0700 Subject: [PATCH 01/22] Uniquify csv output from multigather Hello! Hope you and yours are doing well. I am using multigather to query protein sequences against each other, and primarily use the csv file for downstream processing. As is, if the signatures in `--query query.sig` were created from one fasta file with e.g. `--singleton`, then all results iteratively overwrite each other into the same csv file. This proposed change adds the md5sum to the query file to ensure uniqueness. Other suggestions are welcome! (not tested yet) Warmest, Olga --- src/sourmash/commands.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/sourmash/commands.py b/src/sourmash/commands.py index c80b242cc1..4e589d1287 100644 --- a/src/sourmash/commands.py +++ b/src/sourmash/commands.py @@ -998,9 +998,10 @@ def multigather(args): query_filename = query.filename if not query_filename: # use md5sum if query.filename not properly set - query_filename = query.md5sum() - - output_base = os.path.basename(query_filename) + output_base = query.md5sum() + else: + # Uniquify the output file if all signatures were made from the same file (e.g. with --singleton) + output_base = os.path.basename(query_filename) + "." + query.md5sum() output_csv = output_base + '.csv' w = None From db99f6d09b3fdeb80fcb3c0aa792716cc6a245d7 Mon Sep 17 00:00:00 2001 From: "C. Titus Brown" Date: Sat, 19 Aug 2023 07:44:59 -0700 Subject: [PATCH 02/22] fix merge mistake --- src/sourmash/commands.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sourmash/commands.py b/src/sourmash/commands.py index 5862b25392..3feb8c7964 100644 --- a/src/sourmash/commands.py +++ b/src/sourmash/commands.py @@ -1085,7 +1085,6 @@ def multigather(args): output_base = os.path.basename(query_filename) + "." + query.md5sum() query_filename = query.md5sum() - output_base = os.path.basename(query_filename) if args.output_dir: output_base = os.path.join(args.output_dir, output_base) From 29b7acf255a3e4268fc5b0346a3535376d031a64 Mon Sep 17 00:00:00 2001 From: "C. Titus Brown" Date: Sat, 19 Aug 2023 08:07:24 -0700 Subject: [PATCH 03/22] update 2065 --- src/sourmash/cli/multigather.py | 4 ++++ src/sourmash/commands.py | 8 ++++++-- tests/test_sourmash.py | 6 +++--- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/sourmash/cli/multigather.py b/src/sourmash/cli/multigather.py index cf20a32cd2..d826aeb46f 100644 --- a/src/sourmash/cli/multigather.py +++ b/src/sourmash/cli/multigather.py @@ -87,6 +87,10 @@ def subparser(subparsers): '--output-dir', '--outdir', help='output CSV results to this directory', ) + subparser.add_argument( + '-U', '--output-add-query-md5sum', + help='add md5sum of each query to ensure unique output file names' + ) add_ksize_arg(subparser) add_moltype_args(subparser) diff --git a/src/sourmash/commands.py b/src/sourmash/commands.py index 3feb8c7964..e9c8861e72 100644 --- a/src/sourmash/commands.py +++ b/src/sourmash/commands.py @@ -1077,13 +1077,17 @@ def multigather(args): continue query_filename = query.filename + print('XYZ', query_filename) if not query_filename: # use md5sum if query.filename not properly set output_base = query.md5sum() - else: + elif args.output_add_query_md5sum: # Uniquify the output file if all signatures were made from the same file (e.g. with --singleton) output_base = os.path.basename(query_filename) + "." + query.md5sum() - query_filename = query.md5sum() + else: + output_base = os.path.basename(query_filename) + + print('XXX', output_base) if args.output_dir: output_base = os.path.join(args.output_dir, output_base) diff --git a/tests/test_sourmash.py b/tests/test_sourmash.py index 9d113a3c6e..8550a155ec 100644 --- a/tests/test_sourmash.py +++ b/tests/test_sourmash.py @@ -3874,7 +3874,7 @@ def test_multigather_metagenome_output(runtmp): cmd = cmd.split(' ') c.run_sourmash(*cmd) - output_csv = runtmp.output('-.csv') + output_csv = runtmp.output('-.csv') # @CTB assert os.path.exists(output_csv) with open(output_csv, newline='') as fp: x = fp.readlines() @@ -3904,7 +3904,7 @@ def test_multigather_metagenome_output_outdir(runtmp): cmd = cmd.split(' ') c.run_sourmash(*cmd) - output_csv = runtmp.output('savehere/-.csv') + output_csv = runtmp.output('savehere/-.csv') # @CTB assert os.path.exists(output_csv) with open(output_csv, newline='') as fp: x = fp.readlines() @@ -5072,7 +5072,7 @@ def test_multigather_output_unassigned_with_abundance(runtmp): assert "the recovered matches hit 91.0% of the abundance-weighted query." in out assert "the recovered matches hit 57.2% of the query k-mers (unweighted)." in out - assert os.path.exists(c.output('r3.fa.unassigned.sig')) + assert os.path.exists(c.output('r3.fa.unassigned.sig')) # @CTB nomatch = sourmash.load_one_signature(c.output('r3.fa.unassigned.sig')) assert nomatch.minhash.track_abundance From 096e1168ff06235af7ba222265a8174ba941c8f7 Mon Sep 17 00:00:00 2001 From: "C. Titus Brown" Date: Sat, 19 Aug 2023 09:03:40 -0700 Subject: [PATCH 04/22] deal with overwriting in tests --- src/sourmash/cli/multigather.py | 2 +- src/sourmash/commands.py | 14 ++++++++++---- tests/test_sourmash.py | 18 ++++++++++-------- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/sourmash/cli/multigather.py b/src/sourmash/cli/multigather.py index d826aeb46f..2592959615 100644 --- a/src/sourmash/cli/multigather.py +++ b/src/sourmash/cli/multigather.py @@ -88,7 +88,7 @@ def subparser(subparsers): help='output CSV results to this directory', ) subparser.add_argument( - '-U', '--output-add-query-md5sum', + '-U', '--output-add-query-md5sum', action='store_true', help='add md5sum of each query to ensure unique output file names' ) diff --git a/src/sourmash/commands.py b/src/sourmash/commands.py index e9c8861e72..b48601af83 100644 --- a/src/sourmash/commands.py +++ b/src/sourmash/commands.py @@ -964,6 +964,7 @@ def multigather(args): # run gather on all the queries. n=0 size_may_be_inaccurate = False + output_base_tracking = set() # make sure we are not reusing 'output_base' for queryfile in inp_files: # load the query signature(s) & figure out all the things for query in sourmash_args.load_file_as_signatures(queryfile, @@ -1077,8 +1078,7 @@ def multigather(args): continue query_filename = query.filename - print('XYZ', query_filename) - if not query_filename: + if not query_filename or query_filename == '-': # use md5sum if query.filename not properly set output_base = query.md5sum() elif args.output_add_query_md5sum: @@ -1087,11 +1087,17 @@ def multigather(args): else: output_base = os.path.basename(query_filename) - print('XXX', output_base) - if args.output_dir: output_base = os.path.join(args.output_dir, output_base) + # track overwrites + if output_base in output_base_tracking: + error(f"ERROR: detected overwritten outputs! '{output_base}' has already been used. Failing.") + error("Consider using '-U/----output-add-query-md5sum'.") + sys.exit(-1) + + output_base_tracking.add(output_base) + output_csv = output_base + '.csv' notify(f'saving all CSV matches to "{output_csv}"') diff --git a/tests/test_sourmash.py b/tests/test_sourmash.py index 8550a155ec..fa8cbad232 100644 --- a/tests/test_sourmash.py +++ b/tests/test_sourmash.py @@ -4006,6 +4006,9 @@ def test_multigather_metagenome_query_with_sbt_addl_query(c): testdata_glob = utils.get_test_data('gather/GCF*.sig') testdata_sigs = glob.glob(testdata_glob) + another_query = utils.get_test_data('gather/GCF_000195995.1_ASM19599v1_genomic.fna.gz.sig') + + testdata_sigs.remove(another_query) query_sig = utils.get_test_data('gather/combined.sig') @@ -4016,9 +4019,7 @@ def test_multigather_metagenome_query_with_sbt_addl_query(c): assert os.path.exists(c.output('gcf_all.sbt.zip')) - another_query = utils.get_test_data('gather/GCF_000195995.1_ASM19599v1_genomic.fna.gz.sig') - - cmd = 'multigather --query {} gcf_all.sbt.zip --db gcf_all.sbt.zip -k 21 --threshold-bp=0'.format(another_query) + cmd = 'multigather --query {} gcf_all.sbt.zip --db gcf_all.sbt.zip {} -k 21 --threshold-bp=0 -U'.format(another_query, another_query) cmd = cmd.split(' ') c.run_sourmash(*cmd) @@ -4027,7 +4028,7 @@ def test_multigather_metagenome_query_with_sbt_addl_query(c): err = c.last_result.err print(err) - assert 'conducted gather searches on 13 signatures' in err + assert 'conducted gather searches on 12 signatures' in err assert 'the recovered matches hit 100.0% of the query' in out #check for matches to some of the sbt signatures assert all(('4.7 Mbp 100.0% 100.0%' in out, @@ -4049,6 +4050,9 @@ def test_multigather_metagenome_sbt_query_from_file_with_addl_query(c): testdata_glob = utils.get_test_data('gather/GCF*.sig') testdata_sigs = glob.glob(testdata_glob) + another_query = utils.get_test_data('gather/GCF_000195995.1_ASM19599v1_genomic.fna.gz.sig') + + testdata_sigs.remove(another_query) query_sig = utils.get_test_data('gather/combined.sig') @@ -4064,9 +4068,7 @@ def test_multigather_metagenome_sbt_query_from_file_with_addl_query(c): with open(query_list, 'wt') as fp: print('gcf_all.sbt.zip', file=fp) - another_query = utils.get_test_data('gather/GCF_000195995.1_ASM19599v1_genomic.fna.gz.sig') - - cmd = 'multigather --query {} --query-from-file {} --db gcf_all.sbt.zip -k 21 --threshold-bp=0'.format(another_query, query_list) + cmd = 'multigather --query {} --query-from-file {} --db gcf_all.sbt.zip {} -k 21 --threshold-bp=0 -U'.format(another_query, query_list, another_query) cmd = cmd.split(' ') c.run_sourmash(*cmd) @@ -4075,7 +4077,7 @@ def test_multigather_metagenome_sbt_query_from_file_with_addl_query(c): err = c.last_result.err print(err) - assert 'conducted gather searches on 13 signatures' in err + assert 'conducted gather searches on 12 signatures' in err assert 'the recovered matches hit 100.0% of the query' in out #check for matches to some of the sbt signatures assert all(('4.7 Mbp 100.0% 100.0%' in out, From 8a54cc589dc4f05d2cd481fd53bb025791cf4a5c Mon Sep 17 00:00:00 2001 From: "C. Titus Brown" Date: Sat, 19 Aug 2023 09:08:17 -0700 Subject: [PATCH 05/22] add tests for detecting overwrite --- tests/test_sourmash.py | 44 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/tests/test_sourmash.py b/tests/test_sourmash.py index fa8cbad232..a548ffea01 100644 --- a/tests/test_sourmash.py +++ b/tests/test_sourmash.py @@ -4001,8 +4001,8 @@ def test_multigather_metagenome_query_on_lca_db(c): 'NC_011663.1 Shewanella baltica OS223,' in out)) -@utils.in_tempdir -def test_multigather_metagenome_query_with_sbt_addl_query(c): +def test_multigather_metagenome_query_with_sbt_addl_query(runtmp): + c = runtmp testdata_glob = utils.get_test_data('gather/GCF*.sig') testdata_sigs = glob.glob(testdata_glob) @@ -4019,7 +4019,7 @@ def test_multigather_metagenome_query_with_sbt_addl_query(c): assert os.path.exists(c.output('gcf_all.sbt.zip')) - cmd = 'multigather --query {} gcf_all.sbt.zip --db gcf_all.sbt.zip {} -k 21 --threshold-bp=0 -U'.format(another_query, another_query) + cmd = 'multigather --query {} gcf_all.sbt.zip --db gcf_all.sbt.zip {} -k 21 --threshold-bp=0'.format(another_query, another_query) cmd = cmd.split(' ') c.run_sourmash(*cmd) @@ -4045,8 +4045,40 @@ def test_multigather_metagenome_query_with_sbt_addl_query(c): 'NC_003198.1 Salmonella enterica subsp' in out)) -@utils.in_tempdir -def test_multigather_metagenome_sbt_query_from_file_with_addl_query(c): +def test_multigather_metagenome_query_with_sbt_addl_query_fail_overwrite(runtmp): + # provide multiple identical queries - fails + c = runtmp + + testdata_glob = utils.get_test_data('gather/GCF*.sig') + testdata_sigs = glob.glob(testdata_glob) + another_query = utils.get_test_data('gather/GCF_000195995.1_ASM19599v1_genomic.fna.gz.sig') + + query_sig = utils.get_test_data('gather/combined.sig') + + cmd = ['index', 'gcf_all.sbt.zip'] + cmd.extend(testdata_sigs) + cmd.extend(['-k', '21']) + c.run_sourmash(*cmd) + + assert os.path.exists(c.output('gcf_all.sbt.zip')) + + cmd = 'multigather --query {} {} --db gcf_all.sbt.zip -k 21 --threshold-bp=0'.format(another_query, another_query) + cmd = cmd.split(' ') + + + with pytest.raises(SourmashCommandFailed) as exc: + c.run_sourmash(*cmd) + + out = c.last_result.out + print(out) + err = c.last_result.err + print(err) + + assert "ERROR: detected overwritten outputs! 'GCF_000195995.1_ASM19599v1_genomic.fna.gz' has already been used. Failing." in err + + +def test_multigather_metagenome_sbt_query_from_file_with_addl_query(runtmp): + c = runtmp testdata_glob = utils.get_test_data('gather/GCF*.sig') testdata_sigs = glob.glob(testdata_glob) @@ -4068,7 +4100,7 @@ def test_multigather_metagenome_sbt_query_from_file_with_addl_query(c): with open(query_list, 'wt') as fp: print('gcf_all.sbt.zip', file=fp) - cmd = 'multigather --query {} --query-from-file {} --db gcf_all.sbt.zip {} -k 21 --threshold-bp=0 -U'.format(another_query, query_list, another_query) + cmd = 'multigather --query {} --query-from-file {} --db gcf_all.sbt.zip {} -k 21 --threshold-bp=0'.format(another_query, query_list, another_query) cmd = cmd.split(' ') c.run_sourmash(*cmd) From 5fcd12a02977a463f168ecb2ec1cee06905c3ab7 Mon Sep 17 00:00:00 2001 From: "C. Titus Brown" Date: Sat, 19 Aug 2023 09:10:59 -0700 Subject: [PATCH 06/22] fix filename == '-' issue --- tests/test_sourmash.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_sourmash.py b/tests/test_sourmash.py index a548ffea01..dfc0586dd4 100644 --- a/tests/test_sourmash.py +++ b/tests/test_sourmash.py @@ -3874,7 +3874,10 @@ def test_multigather_metagenome_output(runtmp): cmd = cmd.split(' ') c.run_sourmash(*cmd) - output_csv = runtmp.output('-.csv') # @CTB + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + output_csv = runtmp.output('b92dbf45dd57867cbec2321ccfa55af8.csv') assert os.path.exists(output_csv) with open(output_csv, newline='') as fp: x = fp.readlines() @@ -3904,7 +3907,7 @@ def test_multigather_metagenome_output_outdir(runtmp): cmd = cmd.split(' ') c.run_sourmash(*cmd) - output_csv = runtmp.output('savehere/-.csv') # @CTB + output_csv = runtmp.output('savehere/b92dbf45dd57867cbec2321ccfa55af8.csv') assert os.path.exists(output_csv) with open(output_csv, newline='') as fp: x = fp.readlines() @@ -5106,7 +5109,7 @@ def test_multigather_output_unassigned_with_abundance(runtmp): assert "the recovered matches hit 91.0% of the abundance-weighted query." in out assert "the recovered matches hit 57.2% of the query k-mers (unweighted)." in out - assert os.path.exists(c.output('r3.fa.unassigned.sig')) # @CTB + assert os.path.exists(c.output('r3.fa.unassigned.sig')) nomatch = sourmash.load_one_signature(c.output('r3.fa.unassigned.sig')) assert nomatch.minhash.track_abundance From 323d2c321dc5d47087a4aef7f734235c514cb632 Mon Sep 17 00:00:00 2001 From: "C. Titus Brown" Date: Sun, 20 Aug 2023 17:56:52 -0700 Subject: [PATCH 07/22] MRG: update #2065 (uniquify CSV output from multigather) (#2721) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Note: PR into #2065. This PR updates #2065 with all the changes from `latest`. This includes the fix & update to multigather in https://github.com/sourmash-bio/sourmash/pull/2322, which: - fixes the output of multigather to include more than one line 😆 - prints out the output filename - supports `--output-dir` No functional changes are made beyond the merge, so some tests are still failing; will discuss fixes in yet a new PR :). --------- Signed-off-by: dependabot[bot] Co-authored-by: Keya Barve <53328492+keyabarve@users.noreply.github.com> Co-authored-by: ccbaumler <63077899+ccbaumler@users.noreply.github.com> Co-authored-by: Tessa Pierce Ward Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Taylor Reiter Co-authored-by: Erik Young Co-authored-by: David Koslicki Co-authored-by: Luiz Irber Co-authored-by: Colton Baumler Co-authored-by: Luiz Irber Co-authored-by: N. Tessa Pierce-Ward Co-authored-by: Peter Cock Co-authored-by: Francesco Beghini Co-authored-by: Jason Stajich Co-authored-by: Katrin Leinweber <9948149+katrinleinweber@users.noreply.github.com> --- .ci/install_cargo.sh | 7 +- .github/PULL_REQUEST_TEMPLATE.md | 6 +- .github/workflows/asv.yml | 2 +- .github/workflows/build_wheel.yml | 62 +- .github/workflows/build_wheel_all_archs.yml | 99 + .github/workflows/dev_envs.yml | 26 +- .github/workflows/draft-pdf.yml | 23 + .github/workflows/hypothesis.yml | 9 +- .github/workflows/metadata.yml | 2 +- .github/workflows/python.yml | 25 +- .github/workflows/rust.yml | 20 +- .gitignore | 6 +- .pre-commit-config.yaml | 8 +- .readthedocs.yml | 10 +- CITATION.cff | 2 +- Cargo.lock | 882 ++++- Cargo.toml | 4 + Makefile | 15 +- README.md | 2 +- asv.conf.json | 5 +- binder/environment.yml | 4 +- codecov.yml | 5 +- codemeta.json | 4 +- doc/_static/Sourmash_flow_diagrams_QC.png | Bin 0 -> 40519 bytes .../Sourmash_flow_diagrams_compute.png | Bin 0 -> 17250 bytes doc/_static/Sourmash_flow_diagrams_search.png | Bin 0 -> 30646 bytes doc/_static/kmers-metapalette.png | Bin 0 -> 203240 bytes doc/_static/schurch_comp.matrix.png | Bin 0 -> 323492 bytes doc/api-example.md | 2 +- doc/classifying-signatures.md | 8 +- doc/command-line.md | 511 ++- doc/conf.py | 8 +- doc/databases.md | 115 +- doc/dev_plugins.md | 95 + doc/developer.md | 33 +- doc/environment.yml | 6 - doc/index.md | 2 +- doc/kmers-and-minhash.ipynb | 28 +- doc/other-languages.md | 5 +- doc/plotting-compare.ipynb | 116 +- doc/release-notes/releases.md | 2 +- doc/release-notes/sourmash-2.0.md | 2 +- doc/release-notes/sourmash-3.0.md | 26 +- doc/release-notes/sourmash-4.0.md | 4 +- doc/release.md | 194 +- doc/sourmash-collections.ipynb | 2 +- doc/sourmash-examples.ipynb | 6 +- doc/sourmash-sketch.md | 49 +- doc/tutorial-lemonade.md | 470 +++ doc/tutorial-lin-taxonomy.md | 528 +++ doc/tutorial-long.md | 567 +++ doc/tutorials.md | 22 +- doc/using-LCA-database-API.ipynb | 62 +- doc/using-sourmash-a-guide.md | 11 + flake.lock | 138 +- flake.nix | 65 +- include/sourmash.h | 1 + paper.bib | 61 +- paper.md | 162 +- pyproject.toml | 171 +- setup.cfg | 102 - setup.py | 69 - src/core/Cargo.toml | 56 +- src/core/README.md | 16 +- src/core/build.rs | 75 + src/core/src/encodings.rs | 6 +- src/core/src/errors.rs | 11 +- src/core/src/ffi/cmd/compute.rs | 4 +- src/core/src/ffi/minhash.rs | 8 +- src/core/src/ffi/nodegraph.rs | 2 +- src/core/src/ffi/signature.rs | 17 +- src/core/src/ffi/storage.rs | 4 +- src/core/src/index/linear.rs | 2 +- src/core/src/index/mod.rs | 2 +- src/core/src/index/revindex.rs | 10 +- src/core/src/index/sbt/mod.rs | 19 +- src/core/src/lib.rs | 3 +- src/core/src/signature.rs | 12 +- src/core/src/sketch/hyperloglog/estimators.rs | 2 +- src/core/src/sketch/hyperloglog/mod.rs | 4 +- src/core/src/sketch/minhash.rs | 62 +- src/core/src/sketch/nodegraph.rs | 6 +- src/core/src/storage.rs | 15 +- src/core/tests/minhash.rs | 36 +- src/sourmash/__init__.py | 21 +- src/sourmash/__main__.py | 13 +- src/sourmash/cli/__init__.py | 29 +- src/sourmash/cli/categorize.py | 2 +- src/sourmash/cli/compare.py | 27 +- src/sourmash/cli/gather.py | 17 +- src/sourmash/cli/index.py | 2 +- src/sourmash/cli/info.py | 3 + src/sourmash/cli/lca/index.py | 2 +- src/sourmash/cli/multigather.py | 18 +- src/sourmash/cli/prefetch.py | 2 +- src/sourmash/cli/sbt_combine.py | 10 - src/sourmash/cli/scripts/__init__.py | 48 + src/sourmash/cli/search.py | 17 +- src/sourmash/cli/sig/cat.py | 24 +- src/sourmash/cli/sig/check.py | 2 +- src/sourmash/cli/sig/collect.py | 2 +- src/sourmash/cli/sig/describe.py | 26 +- src/sourmash/cli/sig/downsample.py | 30 +- src/sourmash/cli/sig/export.py | 17 +- src/sourmash/cli/sig/extract.py | 41 +- src/sourmash/cli/sig/filter.py | 25 +- src/sourmash/cli/sig/flatten.py | 22 +- src/sourmash/cli/sig/grep.py | 2 +- src/sourmash/cli/sig/inflate.py | 2 +- src/sourmash/cli/sig/ingest.py | 2 +- src/sourmash/cli/sig/intersect.py | 30 +- src/sourmash/cli/sig/kmers.py | 52 +- src/sourmash/cli/sig/merge.py | 30 +- src/sourmash/cli/sig/overlap.py | 28 +- src/sourmash/cli/sig/rename.py | 21 +- src/sourmash/cli/sig/split.py | 46 +- src/sourmash/cli/sig/subtract.py | 28 +- src/sourmash/cli/sketch/fromfile.py | 4 + src/sourmash/cli/tax/__init__.py | 3 + src/sourmash/cli/tax/annotate.py | 12 +- src/sourmash/cli/tax/genome.py | 64 +- src/sourmash/cli/tax/grep.py | 72 + src/sourmash/cli/tax/metagenome.py | 48 +- src/sourmash/cli/tax/prepare.py | 2 +- src/sourmash/cli/tax/summarize.py | 56 + src/sourmash/cli/utils.py | 79 +- src/sourmash/cli/watch.py | 2 +- src/sourmash/command_compute.py | 2 +- src/sourmash/command_sketch.py | 10 +- src/sourmash/commands.py | 299 +- src/sourmash/distance_utils.py | 26 +- src/sourmash/exceptions.py | 5 + src/sourmash/index/__init__.py | 354 +- src/sourmash/index/sqlite_index.py | 21 +- src/sourmash/lca/__init__.py | 2 +- src/sourmash/lca/command_classify.py | 5 +- src/sourmash/lca/command_index.py | 4 +- src/sourmash/lca/command_summarize.py | 6 +- src/sourmash/lca/lca_db.py | 10 +- src/sourmash/lca/lca_utils.py | 12 +- src/sourmash/manifest.py | 13 +- src/sourmash/minhash.py | 133 +- src/sourmash/picklist.py | 23 +- src/sourmash/plugins.py | 197 + src/sourmash/save_load.py | 530 +++ src/sourmash/sbt.py | 6 +- src/sourmash/sbt_storage.py | 12 +- src/sourmash/search.py | 133 +- src/sourmash/sig/__init__.py | 2 +- src/sourmash/sig/__main__.py | 44 +- src/sourmash/signature.py | 89 +- src/sourmash/sketchcomparison.py | 36 +- src/sourmash/sourmash_args.py | 715 +--- src/sourmash/tax/__main__.py | 475 ++- src/sourmash/tax/tax_utils.py | 2134 ++++++++-- src/sourmash/utils.py | 2 +- tests/conftest.py | 7 +- tests/sourmash_tst_utils.py | 31 +- tests/test-data/47-63-merge.sig | 1 + tests/test-data/picklist/empty.csv | 1 + .../tax/47+63_x_gtdb-rs202.gather.csv | 6 +- tests/test-data/tax/gtdb-tax-grep.sigs.zip | Bin 0 -> 132319 bytes tests/test-data/tax/lemonade-MAG3.sig.gz | Bin 0 -> 18917 bytes tests/test-data/tax/lemonade-MAG3.x.gtdb.csv | 2 + .../tax/lemonade-MAG3.x.gtdb.matches.tax.csv | 4 + .../tax/lemonade-MAG3.x.gtdb.matches.zip | Bin 0 -> 61565 bytes .../tax/test-empty-line.taxonomy.csv | 8 + tests/test-data/tax/test.LIN-taxonomy.csv | 7 + tests/test-data/tax/test.ncbi-taxonomy.csv | 7 + tests/test-data/tax/test1.gather.csv | 10 +- tests/test-data/tax/test1.gather.v450.csv | 5 + tests/test-data/tax/test1.gather_old.csv | 5 + tests/test-data/tax/test1.sig | 1 + .../test1_x_gtdbrs202_genbank_euks.gather.csv | 14 +- tests/test_api.py | 2 +- tests/test_cmd_signature.py | 197 +- tests/test_cmd_signature_collect.py | 60 +- tests/test_cmd_signature_grep.py | 35 + tests/test_compare.py | 26 +- tests/test_distance_utils.py | 37 +- tests/test_index.py | 820 +--- tests/test_index_protocol.py | 872 ++++- tests/test_lca.py | 315 +- tests/test_minhash.py | 213 +- tests/test_picklist.py | 23 + tests/test_plugin_framework.py | 541 +++ tests/test_prefetch.py | 69 +- tests/test_sbt.py | 70 +- tests/test_search.py | 70 +- tests/test_signature.py | 80 +- tests/test_sketchcomparison.py | 106 +- tests/test_sourmash.py | 1606 ++++++-- tests/test_sourmash_args.py | 252 +- tests/test_sourmash_sketch.py | 98 + tests/test_sqlite_index.py | 38 + tests/test_tax.py | 2968 +++++++++++--- tests/test_tax_utils.py | 3418 ++++++++++++++--- tox.ini | 32 +- 198 files changed, 18211 insertions(+), 5372 deletions(-) create mode 100644 .github/workflows/build_wheel_all_archs.yml create mode 100644 .github/workflows/draft-pdf.yml create mode 100644 doc/_static/Sourmash_flow_diagrams_QC.png create mode 100644 doc/_static/Sourmash_flow_diagrams_compute.png create mode 100644 doc/_static/Sourmash_flow_diagrams_search.png create mode 100644 doc/_static/kmers-metapalette.png create mode 100644 doc/_static/schurch_comp.matrix.png create mode 100644 doc/dev_plugins.md delete mode 100644 doc/environment.yml create mode 100644 doc/tutorial-lemonade.md create mode 100644 doc/tutorial-lin-taxonomy.md create mode 100644 doc/tutorial-long.md delete mode 100644 setup.cfg delete mode 100644 setup.py create mode 100644 src/core/build.rs create mode 100644 src/sourmash/cli/scripts/__init__.py create mode 100644 src/sourmash/cli/tax/grep.py create mode 100644 src/sourmash/cli/tax/summarize.py create mode 100644 src/sourmash/plugins.py create mode 100644 src/sourmash/save_load.py create mode 100644 tests/test-data/47-63-merge.sig create mode 100644 tests/test-data/picklist/empty.csv create mode 100644 tests/test-data/tax/gtdb-tax-grep.sigs.zip create mode 100644 tests/test-data/tax/lemonade-MAG3.sig.gz create mode 100644 tests/test-data/tax/lemonade-MAG3.x.gtdb.csv create mode 100644 tests/test-data/tax/lemonade-MAG3.x.gtdb.matches.tax.csv create mode 100644 tests/test-data/tax/lemonade-MAG3.x.gtdb.matches.zip create mode 100644 tests/test-data/tax/test-empty-line.taxonomy.csv create mode 100644 tests/test-data/tax/test.LIN-taxonomy.csv create mode 100644 tests/test-data/tax/test.ncbi-taxonomy.csv create mode 100644 tests/test-data/tax/test1.gather.v450.csv create mode 100644 tests/test-data/tax/test1.gather_old.csv create mode 100644 tests/test-data/tax/test1.sig create mode 100644 tests/test_picklist.py create mode 100644 tests/test_plugin_framework.py diff --git a/.ci/install_cargo.sh b/.ci/install_cargo.sh index 94ec2b9beb..8635836ef6 100755 --- a/.ci/install_cargo.sh +++ b/.ci/install_cargo.sh @@ -1,5 +1,10 @@ #! /bin/sh -curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain stable +curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain=stable +rustup show export PATH="$HOME/.cargo/bin:$PATH" rustc -V rustup target add aarch64-apple-darwin + +# update crates.io index without updating Cargo.lock +export CARGO_NET_GIT_FETCH_WITH_CLI=true +cargo update --dry-run diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7fa8806218..a436131398 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,8 +8,10 @@ Please also be sure to note here if file formats, command-line interface, and/or the top-level sourmash API will change because of this PR. -If you are a new contributor, please provide -[your ORCID](https://orcid.org). If you don't have one, please +If you are a new contributor, please add your name and +[your ORCID](https://orcid.org) to the `pyproject.toml` author list +(maintaining alphabetical order by last name). +If you don't have an ORCID, please [register for one](https://orcid.org/register). Once the items above are done, and all checks pass, request a review! diff --git a/.github/workflows/asv.yml b/.github/workflows/asv.yml index b4b130456a..6523e70031 100644 --- a/.github/workflows/asv.yml +++ b/.github/workflows/asv.yml @@ -23,7 +23,7 @@ jobs: git checkout - - name: Set up Python 3.10 - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: "3.10" diff --git a/.github/workflows/build_wheel.yml b/.github/workflows/build_wheel.yml index da2440a72c..c7b13ec599 100644 --- a/.github/workflows/build_wheel.yml +++ b/.github/workflows/build_wheel.yml @@ -16,29 +16,14 @@ jobs: matrix: build: [ linux-x86_64, - linux-aarch64, - linux-ppc64le, - linux-s390x, macos-x86_64, macos-arm64, ] include: - build: linux-x86_64 - os: ubuntu-18.04 + os: ubuntu-20.04 arch: x86_64 macos_target: '' - - build: linux-aarch64 - os: ubuntu-18.04 - arch: aarch64 - macos_target: '' - - build: linux-ppc64le - os: ubuntu-18.04 - arch: ppc64le - macos_target: '' - - build: linux-s390x - os: ubuntu-18.04 - arch: s390x - macos_target: '' - build: macos-x86_64 os: macos-latest arch: x86_64 @@ -46,7 +31,7 @@ jobs: - build: macos-arm64 os: macos-latest arch: arm64 - macos_target: 'MACOSX_DEPLOYMENT_TARGET=11 CARGO_BUILD_TARGET=aarch64-apple-darwin' + macos_target: 'MACOSX_DEPLOYMENT_TARGET=11.0 CARGO_BUILD_TARGET=aarch64-apple-darwin' fail-fast: false steps: @@ -54,19 +39,13 @@ jobs: with: fetch-depth: 0 - - uses: actions/setup-python@v3 + - uses: actions/setup-python@v4 name: Install Python with: python-version: '3.9' - - name: Set up QEMU - if: runner.os == 'Linux' - uses: docker/setup-qemu-action@v2 - with: - platforms: all - - name: Build wheels - uses: pypa/cibuildwheel@2.5.0 + uses: pypa/cibuildwheel@v2.15.0 env: CIBW_ENVIRONMENT_MACOS: ${{ matrix.macos_target }} CIBW_ARCHS_LINUX: ${{ matrix.arch }} @@ -76,11 +55,40 @@ jobs: with: path: './wheelhouse/sourmash*.whl' + build_wasm: + runs-on: ubuntu-20.04 + env: + PYODIDE_VERSION: "0.23.0" + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: "3.11.2" + - run: | + pip install pyodide-build==${PYODIDE_VERSION} "pydantic<2" + pyodide config get emscripten_version # trigger setup + echo EMSCRIPTEN_VERSION=$(pyodide config get emscripten_version) >> $GITHUB_ENV + - uses: mymindstorm/setup-emsdk@v12 + with: + version: ${{ env.EMSCRIPTEN_VERSION }} + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + target: wasm32-unknown-emscripten + - run: | + export RUSTC_BOOTSTRAP=1 + pyodide build + + - uses: actions/upload-artifact@v3 + with: + path: './dist/sourmash*.whl' + + release: name: Publish wheels - runs-on: ubuntu-18.04 + runs-on: ubuntu-20.04 if: startsWith(github.ref, 'refs/tags/v') - needs: build_wheels + needs: [build_wheels, build_wasm] steps: - name: Fetch wheels from artifacts diff --git a/.github/workflows/build_wheel_all_archs.yml b/.github/workflows/build_wheel_all_archs.yml new file mode 100644 index 0000000000..cc4c7a3587 --- /dev/null +++ b/.github/workflows/build_wheel_all_archs.yml @@ -0,0 +1,99 @@ +name: cibuildwheel_ubuntu + +on: + #pull_request: # use for testing modifications to this action + push: + branches: [latest] + tags: v* + schedule: + - cron: "0 0 * * *" # daily + +jobs: + build_wheels: + name: Build wheels for ${{ matrix.os }}-${{ matrix.arch }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + build: [ + linux-aarch64, + linux-ppc64le, + linux-s390x, + ] + include: + - build: linux-aarch64 + os: ubuntu-20.04 + arch: aarch64 + macos_target: '' + - build: linux-ppc64le + os: ubuntu-20.04 + arch: ppc64le + macos_target: '' + - build: linux-s390x + os: ubuntu-20.04 + arch: s390x + macos_target: '' + fail-fast: false + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v4 + name: Install Python + with: + python-version: '3.9' + + # Added due to weird error when building inside docker container + # for other platforms... + # https://github.com/JonasAlfredsson/docker-nginx-certbot/issues/30 + - name: Set Swap Space + if: runner.os == 'Linux' + uses: pierotofy/set-swap-space@v1.0 + with: + swap-size-gb: 10 + - run: | + # Workaround for https://github.com/rust-lang/cargo/issues/8719 + sudo mkdir -p /var/lib/docker + sudo mount -t tmpfs -o size=10G none /var/lib/docker + sudo systemctl restart docker + if: runner.os == 'Linux' + + - name: Set up QEMU + if: runner.os == 'Linux' + uses: docker/setup-qemu-action@v2 + with: + platforms: all + + - name: Build wheels + uses: pypa/cibuildwheel@v2.15.0 + env: + CIBW_ENVIRONMENT_MACOS: ${{ matrix.macos_target }} + CIBW_ARCHS_LINUX: ${{ matrix.arch }} + CIBW_ARCHS_MACOS: ${{ matrix.arch }} + CARGO_NET_GIT_FETCH_WITH_CLI: true + + - uses: actions/upload-artifact@v3 + with: + path: './wheelhouse/sourmash*.whl' + + release: + name: Publish wheels + runs-on: ubuntu-20.04 + if: startsWith(github.ref, 'refs/tags/v') + needs: build_wheels + + steps: + - name: Fetch wheels from artifacts + id: fetch_artifacts + uses: actions/download-artifact@v3 + with: + path: 'wheels/' + + # if it matches a Python release tag, upload to github releases + # TODO: In the future, use the create-release and upload-release-assets actions + - name: Release + uses: fnkr/github-action-ghr@v1 + env: + GHR_PATH: ${{steps.fetch_artifacts.outputs.download-path}}/artifact + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/dev_envs.yml b/.github/workflows/dev_envs.yml index 01933f2c15..c993e0a520 100644 --- a/.github/workflows/dev_envs.yml +++ b/.github/workflows/dev_envs.yml @@ -11,23 +11,19 @@ jobs: with: fetch-depth: 0 - - name: Cache nix store - id: cache-nix - uses: actions/cache@v3 + - uses: cachix/install-nix-action@v22 + with: + extra_nix_config: | + access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} + + - uses: cachix/cachix-action@v12 with: - path: | - ~/.nix-portable/store - ~/bin/nix-portable - key: nix-${{ hashFiles('shell.nix') }}-${{ hashFiles('nix/**') }}-v009 + name: sourmash-bio + authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' - - name: Install nix-portable - if: steps.cache-nix.outputs.cache-hit != 'true' - run: | - mkdir ~/bin - wget -qO ~/bin/nix-portable https://github.com/DavHau/nix-portable/releases/download/v009/nix-portable - chmod +x ~/bin/nix-portable + - run: nix run .# -- --version - - run: ~/bin/nix-portable nix-shell --command "tox -e py39" + - run: nix-shell --command "tox -e py310" mamba: runs-on: ubuntu-latest @@ -46,7 +42,7 @@ jobs: ${{ runner.os }}-conda-${{ env.CACHE_NUMBER }}-${{ hashFiles('environment.yml') }} - name: setup conda - uses: conda-incubator/setup-miniconda@35d1405e78aa3f784fe3ce9a2eb378d5eeb62169 + uses: conda-incubator/setup-miniconda@3b0f2504dd76ef23b6d31f291f4913fb60ab5ff3 with: auto-update-conda: true python-version: 3.9 diff --git a/.github/workflows/draft-pdf.yml b/.github/workflows/draft-pdf.yml new file mode 100644 index 0000000000..cf6d3dbff6 --- /dev/null +++ b/.github/workflows/draft-pdf.yml @@ -0,0 +1,23 @@ +on: [push] + +jobs: + paper: + runs-on: ubuntu-latest + name: Paper Draft + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Build draft PDF + uses: openjournals/openjournals-draft-action@master + with: + journal: joss + # This should be the path to the paper within your repo. + paper-path: paper.md + - name: Upload + uses: actions/upload-artifact@v1 + with: + name: paper + # This is the output path where Pandoc will write the compiled + # PDF. Note, this should be the same directory as the input + # paper.md + path: paper.pdf diff --git a/.github/workflows/hypothesis.yml b/.github/workflows/hypothesis.yml index 7d682e2e16..1776f03683 100644 --- a/.github/workflows/hypothesis.yml +++ b/.github/workflows/hypothesis.yml @@ -16,7 +16,7 @@ jobs: fetch-depth: 0 - name: Set up Python 3.10 - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: "3.10" @@ -27,3 +27,10 @@ jobs: - name: Run Hypothesis tests run: tox -e hypothesis,coverage + + - name: Upload Python coverage to codecov + uses: codecov/codecov-action@v3 + with: + flags: hypothesis-py + fail_ci_if_error: true + files: .tox/coverage.xml diff --git a/.github/workflows/metadata.yml b/.github/workflows/metadata.yml index 417e04400f..32fdcb8fa3 100644 --- a/.github/workflows/metadata.yml +++ b/.github/workflows/metadata.yml @@ -16,4 +16,4 @@ jobs: - name: Trigger new archival in software heritage on new tags if: startsWith(github.ref, 'refs/tags/') - run: curl https://archive.softwareheritage.org/api/1/origin/save/git/url/https://github.com/dib-lab/sourmash.git/ + run: curl https://archive.softwareheritage.org/api/1/origin/save/git/url/https://github.com/sourmash-bio/sourmash.git/ diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index ebcd2f9a38..f8cdf7c4db 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -1,4 +1,4 @@ -# note: to invalidate caches, adjust the pip-v? and tox-v? numbers below. +# note: to invalidate caches, adjust the pip-v? number below. name: Python tests on: @@ -13,8 +13,8 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-18.04, macos-latest] - py: ["3.10", "3.9", "3.8"] + os: [ubuntu-22.04, macos-latest] + py: ["3.11", "3.10", "3.9", "3.8"] fail-fast: false steps: @@ -23,7 +23,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ matrix.py }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.py }} @@ -36,9 +36,9 @@ jobs: uses: actions/cache@v3 with: path: ${{ steps.pip-cache.outputs.dir }} - key: ${{ runner.os }}-pip-v2-${{ hashFiles('**/setup.cfg') }} + key: ${{ runner.os }}-pip-v4-${{ hashFiles('**/pyproject.toml') }} restore-keys: | - ${{ runner.os }}-pip-v2- + ${{ runner.os }}-pip-v4- - name: Install dependencies run: | @@ -57,7 +57,7 @@ jobs: - name: Start Redis if: startsWith(runner.os, 'Linux') && (matrix.py == '3.9') - uses: supercharge/redis-github-action@4b67a313c69bc7a90f162e8d810392fffe10d3b5 + uses: supercharge/redis-github-action@1.6.0 with: redis-version: 6 @@ -65,11 +65,18 @@ jobs: uses: actions/cache@v3 with: path: .tox/ - key: ${{ runner.os }}-tox-v2-${{ hashFiles('**/setup.cfg') }} + key: ${{ runner.os }}-tox-v4-${{ hashFiles('**/pyproject.toml') }} restore-keys: | - ${{ runner.os }}-tox-v2- + ${{ runner.os }}-tox-v4- - name: Test with tox run: tox env: PYTHONDEVMODE: 1 + + - name: Upload Python coverage to codecov + uses: codecov/codecov-action@v3 + with: + flags: python + fail_ci_if_error: true + files: .tox/coverage.xml diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 8d1ecf1a9d..fa16b58900 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -5,6 +5,7 @@ on: branches: [latest] pull_request: paths: + - 'Cargo.lock' - 'src/core/**' - 'tests/test-data/**' - '.github/workflows/rust.yml' @@ -63,7 +64,7 @@ jobs: override: true - name: Set up Python 3.8 - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: "3.8" @@ -90,7 +91,7 @@ jobs: override: true - name: Set up Python 3.8 - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: "3.8" @@ -267,26 +268,17 @@ jobs: - uses: actions-rs/toolchain@v1 with: - toolchain: 1.48.0 + toolchain: "1.64.0" override: true - name: check if README matches MSRV defined here - run: grep '1.48.0' src/core/README.md - - - name: Set up Python 3.8 - uses: actions/setup-python@v3 - with: - python-version: "3.8" - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install -e . + run: grep '1.64.0' src/core/README.md - name: Check if it builds properly uses: actions-rs/cargo@v1 with: command: build + args: --all-features --tests check_cbindgen: name: "Check if cbindgen runs cleanly for generating the C headers" diff --git a/.gitignore b/.gitignore index 968cc71d2e..7a51026aaa 100644 --- a/.gitignore +++ b/.gitignore @@ -17,16 +17,14 @@ sourmash.egg-info *.so .coverage .pytest_cache +**/*.pyc .python-version src/sourmash/version.py *.DS_Store .tox -src/sourmash/_lowlevel*.py +src/sourmash/_lowlevel/* .env -Pipfile -Pipfile.lock target/ -Cargo.lock .eggs .asv pkg/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 42406214f2..bca7329143 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,10 +28,10 @@ repos: # format using black # when the full codebase is black, use it directly; # while it isn't, let's use darker to format new/changed code -- repo: https://github.com/akaihola/darker - rev: 1.2.1 - hooks: - - id: darker +- repo: https://github.com/akaihola/darker + rev: 1.7.1 + hooks: + - id: darker #- repo: https://github.com/psf/black # rev: 20.8b1 # hooks: diff --git a/.readthedocs.yml b/.readthedocs.yml index 0c7ae9f116..f015d9c63d 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -4,15 +4,17 @@ version: 2 +build: + os: ubuntu-22.04 + tools: + python: "3.10" + rust: "1.64" + # Build documentation in the docs/ directory with Sphinx sphinx: configuration: doc/conf.py -conda: - environment: doc/environment.yml - python: - version: 3.8 install: - method: pip path: . diff --git a/CITATION.cff b/CITATION.cff index 35194d5f26..672cca970e 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,6 +1,6 @@ cff-version: 1.0.3 message: If you use this software, please cite it as below. -title: sourmash: a library for MinHash sketching of DNA +title: "sourmash: a library for MinHash sketching of DNA" version: 2.0.0 doi: 10.21105/joss.00027 date-released: 2019-01-10 diff --git a/Cargo.lock b/Cargo.lock index 14e4c55e6b..1b3b7a6569 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,18 +2,23 @@ # It is not intended for manual editing. version = 3 -[[package]] -name = "Inflector" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" - [[package]] name = "adler" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "ahash" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +dependencies = [ + "getrandom", + "once_cell", + "version_check", +] + [[package]] name = "aliasable" version = "0.1.3" @@ -21,22 +26,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" [[package]] -name = "assert_matches" -version = "1.5.0" +name = "android-tzdata" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" [[package]] -name = "atty" -version = "0.2.14" +name = "android_system_properties" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ - "hermit-abi", "libc", - "winapi", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41ed9a86bf92ae6580e0a31281f65a1b1d867c0cc68d5346e2ae128dddfa6a7d" + +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" + [[package]] name = "autocfg" version = "1.1.0" @@ -45,9 +66,9 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "az" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f771a5d1f5503f7f4279a30f3643d3421ba149848b89ecaaec0ea2acf04a5ac4" +checksum = "7b7e4c2464d97fe331d41de9d5db0def0a96f4d823b8b32a2efd503578988973" [[package]] name = "bincode" @@ -64,6 +85,12 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitflags" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42" + [[package]] name = "bstr" version = "0.2.17" @@ -86,17 +113,27 @@ dependencies = [ "safemem", ] +[[package]] +name = "buffer-redux" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2886ea01509598caac116942abd33ab5a88fa32acdf7e4abfa0fc489ca520c9" +dependencies = [ + "memchr", + "safemem", +] + [[package]] name = "bumpalo" -version = "3.9.1" +version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a45a46ab1f2412e53d3a0ade76ffad2025804294569aae387231a0cd6e0899" +checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535" [[package]] name = "bytecount" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72feb31ffc86498dacdbd0fcebb56138e7177a8cc5cea4516031d15ae85a742e" +checksum = "2c676a478f63e9fa2dd5368a42f28bba0d6c560b775f38583c8bbaa7fcd67c9c" [[package]] name = "byteorder" @@ -106,9 +143,9 @@ checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" [[package]] name = "bzip2" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6afcd980b5f3a45017c57e57a2fcccbb351cc43a356ce117ef760ef8052b89b0" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" dependencies = [ "bzip2-sys", "libc", @@ -127,18 +164,15 @@ dependencies = [ [[package]] name = "capnp" -version = "0.14.5" +version = "0.14.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16c262726f68118392269a3f7a5546baf51dcfe5cb3c3f0957b502106bf1a065" +checksum = "2dca085c2c7d9d65ad749d450b19b551efaa8e3476a439bdca07aca8533097f3" [[package]] name = "cast" -version = "0.2.7" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c24dab4283a142afa2fdca129b80ad2c6284e073930f964c3a1293c225ee39a" -dependencies = [ - "rustc_version", -] +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" @@ -154,28 +188,72 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.19" +version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5" dependencies = [ - "libc", - "num-integer", + "android-tzdata", + "iana-time-zone", + "js-sys", "num-traits", "time", + "wasm-bindgen", "winapi", ] +[[package]] +name = "ciborium" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "effd91f6c78e5a4ace8a5d3c0b6bfaec9e2baaef55f3efc00e45fb2e477ee926" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdf919175532b369853f5d5e20b26b43112613fd6fe7aee757e35f7a44642656" + +[[package]] +name = "ciborium-ll" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defaa24ecc093c77630e6c15e17c51f5e187bf35ee514f4e2d67baaa96dae22b" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clap" -version = "2.34.0" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +checksum = "93aae7a4192245f70fe75dd9157fc7b4a5bf53e88d30bd4396f7d8f9284d5acc" dependencies = [ - "bitflags", - "textwrap", - "unicode-width", + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f423e341edefb78c9caba2d9c7f7687d0e72e89df3ce3394554754393ac3990" +dependencies = [ + "anstyle", + "bitflags 1.3.2", + "clap_lex", ] +[[package]] +name = "clap_lex" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b" + [[package]] name = "codepage-437" version = "0.1.0" @@ -185,6 +263,16 @@ dependencies = [ "csv", ] +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + [[package]] name = "console_error_panic_hook" version = "0.1.7" @@ -195,11 +283,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" + [[package]] name = "counter" -version = "0.5.4" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7798e2993e65a31cccd4aea51cea5d768736df1e02b11a26b8935ab14819b176" +checksum = "2d458e66999348f56fd3ffcfbb7f7951542075ca8359687c703de6500c1ddccd" dependencies = [ "num-traits", ] @@ -215,24 +309,24 @@ dependencies = [ [[package]] name = "criterion" -version = "0.3.5" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1604dafd25fba2fe2d5895a9da139f8dc9b319a5fe5354ca137cbbce4e178d10" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" dependencies = [ - "atty", + "anes", "cast", + "ciborium", "clap", "criterion-plot", - "csv", + "is-terminal", "itertools", - "lazy_static", "num-traits", + "once_cell", "oorandom", "plotters", "rayon", "regex", "serde", - "serde_cbor", "serde_derive", "serde_json", "tinytemplate", @@ -241,9 +335,9 @@ dependencies = [ [[package]] name = "criterion-plot" -version = "0.4.4" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d00996de9f2f7559f7f4dc286073197f83e92256a59ed395f9aac01fe717da57" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" dependencies = [ "cast", "itertools", @@ -315,6 +409,50 @@ dependencies = [ "memchr", ] +[[package]] +name = "cxx" +version = "1.0.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add3fc1717409d029b20c5b6903fc0c0b02fa6741d820054f4a2efa5e5816fd" +dependencies = [ + "cc", + "cxxbridge-flags", + "cxxbridge-macro", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c87959ba14bc6fbc61df77c3fcfe180fc32b93538c4f1031dd802ccb5f2ff0" +dependencies = [ + "cc", + "codespan-reporting", + "once_cell", + "proc-macro2", + "quote", + "scratch", + "syn 1.0.104", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69a3e162fde4e594ed2b07d0f83c6c67b745e7f28ce58c6df5e6b6bef99dfb59" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e7e2adeb6a0d4a282e581096b06e1791532b7d576dcde5ccd9382acf55db8e6" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.104", +] + [[package]] name = "either" version = "1.6.1" @@ -322,26 +460,44 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" [[package]] -name = "fastrand" -version = "1.7.0" +name = "errno" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3fcf0cee53519c866c09b5de1f6c56ff9d647101f81c1964fa632e148896cdf" +checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" dependencies = [ - "instant", + "errno-dragonfly", + "libc", + "windows-sys 0.48.0", ] +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "fastrand" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6999dc1837253364c2ebb0704ba97994bd874e8f195d665c50b7548f6ea92764" + [[package]] name = "finch" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f5b421df230ee6000ccb42073103407a8b29c0adc2b5870a346d2fd6281ceec" +checksum = "36c2841b02a42271f46ca5ea2357cf133cab54a63beccb318668be9bbe00d8ae" dependencies = [ "bincode", "capnp", "memmap", "murmurhash3", - "ndarray", - "needletail", + "needletail 0.4.1", + "numpy", "rayon", "serde", "serde_json", @@ -350,9 +506,9 @@ dependencies = [ [[package]] name = "fixedbitset" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "279fb028e20b3c4c320317955b77c5e0c9701f05a1d309905d6fc702cdc5053e" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "flate2" @@ -368,9 +524,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.6" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9be70c98951c83b8d2f8f60d7065fa6d5146873094452a1008da8c2f1e4205ad" +checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" dependencies = [ "cfg-if", "js-sys", @@ -388,7 +544,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn", + "syn 1.0.104", ] [[package]] @@ -397,6 +553,12 @@ version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabb4a44450da02c90444cf74558da904edde8fb4e9035a9a6a4e15445af0bd7" +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "hermit-abi" version = "0.1.19" @@ -407,12 +569,62 @@ dependencies = [ ] [[package]] -name = "instant" -version = "0.1.12" +name = "hermit-abi" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286" + +[[package]] +name = "iana-time-zone" +version = "0.1.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +checksum = "64c122667b287044802d6ce17ee2ddf13207ed924c712de9a66a5814d5b64765" dependencies = [ - "cfg-if", + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "winapi", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca" +dependencies = [ + "cxx", + "cxx-build", +] + +[[package]] +name = "indoc" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adab1eaa3408fb7f0c777a73e7465fd5656136fc93b670eb6df3c88c2c1344e3" + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.1", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "is-terminal" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcf93614601c8129ddf72e2d5633df827ba6551541c6d8c59520a371475be1f" +dependencies = [ + "hermit-abi 0.3.1", + "io-lifetimes", + "rustix 0.37.20", + "windows-sys 0.48.0", ] [[package]] @@ -438,9 +650,9 @@ checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35" [[package]] name = "js-sys" -version = "0.3.57" +version = "0.3.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "671a26f820db17c2a2750743f1dd03bafd15b98c9f30c7c2628c024c05d73397" +checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a" dependencies = [ "wasm-bindgen", ] @@ -453,19 +665,53 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.124" +version = "0.2.146" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a41fed9d98f27ab1c6d161da622a4fa35e8a54a8adc24bbf3ddd0ef70b0e50" +checksum = "f92be4933c13fd498862a9e02a3055f8a8d9c039ce33db97306fd5a6caa7f29b" [[package]] -name = "log" -version = "0.4.16" +name = "libm" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6389c490849ff5bc16be905ae24bc913a9c8892e19b2341dbc175e14c341c2b8" +checksum = "348108ab3fba42ec82ff6e9564fc4ca0247bdccdc68dd8af9764bbc79c3c8ffb" + +[[package]] +name = "link-cplusplus" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecd207c9c713c34f95a097a5b029ac2ce6010530c7b49d7fea24d977dede04f5" dependencies = [ - "cfg-if", + "cc", ] +[[package]] +name = "linux-raw-sys" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" + +[[package]] +name = "linux-raw-sys" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09fc20d2ca12cb9f044c93e3bd6d32d523e6e2ec3db4f7b2939cd99026ecd3f0" + +[[package]] +name = "lock_api" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f80bf5aacaf25cbfc8210d1cfb718f2bf3b11c4c54e5afe36c236853a8ec390" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" + [[package]] name = "lzma-sys" version = "0.1.17" @@ -510,9 +756,9 @@ dependencies = [ [[package]] name = "memmap2" -version = "0.5.3" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "057a3db23999c867821a7a59feb06a578fcb03685e983dff90daf9e7d24ac08f" +checksum = "f49388d20533534cd19360ad3d6a7dadc885944aa802ba3995040c5ec11288c6" dependencies = [ "libc", ] @@ -569,6 +815,17 @@ dependencies = [ "xz2", ] +[[package]] +name = "needletail" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db05a5ab397f64070d8c998fa0fbb84e484b81f95752af317dac183a82d9295d" +dependencies = [ + "buffer-redux", + "bytecount", + "memchr", +] + [[package]] name = "niffler" version = "2.4.0" @@ -618,11 +875,12 @@ dependencies = [ [[package]] name = "num-traits" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -631,15 +889,30 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1" dependencies = [ - "hermit-abi", + "hermit-abi 0.1.19", "libc", ] +[[package]] +name = "numpy" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6522ac2e780f532432a7c7f5dbadbfcea9ff1cf4dd858fb509ca13061a928413" +dependencies = [ + "ahash", + "libc", + "ndarray", + "num-complex", + "num-integer", + "num-traits", + "pyo3", +] + [[package]] name = "once_cell" -version = "1.10.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87f3e037eac156d1775da914196f0f37741a274155e34a0b7e427c35d2a2ecb9" +checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" [[package]] name = "oorandom" @@ -649,26 +922,49 @@ checksum = "0ab1bc2a289d34bd04a330323ac98a1b4bc82c9d9fcb1e66b63caa84da26b575" [[package]] name = "ouroboros" -version = "0.15.0" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f31a3b678685b150cba82b702dcdc5e155893f63610cf388d30cd988d4ca2bf" +checksum = "e2ba07320d39dfea882faa70554b4bd342a5f273ed59ba7c1c6b4c840492c954" dependencies = [ "aliasable", "ouroboros_macro", - "stable_deref_trait", + "static_assertions", ] [[package]] name = "ouroboros_macro" -version = "0.15.0" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "084fd65d5dd8b3772edccb5ffd1e4b7eba43897ecd0f9401e330e8c542959408" +checksum = "ec4c6225c69b4ca778c0aea097321a64c421cf4577b331c61b229267edabb6f8" dependencies = [ - "Inflector", + "heck", "proc-macro-error", "proc-macro2", "quote", - "syn", + "syn 2.0.23", +] + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a279cbf25cb0757810394fbc1e359949b59e348145c643a939a525692e6929" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.2.10", + "smallvec", + "windows-sys 0.36.1", ] [[package]] @@ -728,9 +1024,9 @@ checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" [[package]] name = "primal-check" -version = "0.3.1" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01419cee72c1a1ca944554e23d83e483e1bccf378753344e881de28b5487511d" +checksum = "9df7f93fd637f083201473dab4fee2db4c429d32e55e3299980ab3957ab916a0" dependencies = [ "num-integer", ] @@ -744,7 +1040,7 @@ dependencies = [ "proc-macro-error-attr", "proc-macro2", "quote", - "syn", + "syn 1.0.104", "version_check", ] @@ -761,41 +1057,95 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.36" +version = "1.0.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029" +checksum = "7b368fba921b0dce7e60f5e04ec15e565b3303972b42bcfde1d0713b881959eb" dependencies = [ - "unicode-xid", + "unicode-ident", ] [[package]] name = "proptest" -version = "1.0.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0d9cc07f18492d879586c92b485def06bc850da3118075cd45d50e9c95b0e5" +checksum = "4e35c06b98bf36aba164cc17cb25f7e232f5c4aeea73baa14b8a9f0d92dbfa65" dependencies = [ - "bitflags", + "bitflags 1.3.2", "byteorder", "lazy_static", "num-traits", - "quick-error", "rand", "rand_chacha", "rand_xorshift", "regex-syntax", + "unarray", +] + +[[package]] +name = "pyo3" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f72538a0230791398a0986a6518ebd88abc3fded89007b506ed072acc831e1" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "parking_lot", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc4cf18c20f4f09995f3554e6bcf9b09bd5e4d6b67c562fdfaafa644526ba479" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a41877f28d8ebd600b6aa21a17b40c3b0fc4dfe73a27b6e81ab3d895e401b0e9" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e81c8d4bcc2f216dc1b665412df35e46d12ee8d3d046b381aad05f1fcf30547" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 1.0.104", ] [[package]] -name = "quick-error" -version = "2.0.1" +name = "pyo3-macros-backend" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +checksum = "85752a767ee19399a78272cc2ab625cd7d373b2e112b4b13db28de71fa892784" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.104", +] [[package]] name = "quote" -version = "1.0.15" +version = "1.0.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864d3e96a899863136fc6e99f3d7cae289dafe43bf2c5ac19b70df7210c0a145" +checksum = "573015e8ab27661678357f27dc26460738fd2b6c86e46f386fde94cb5d913105" dependencies = [ "proc-macro2", ] @@ -847,21 +1197,19 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" [[package]] name = "rayon" -version = "1.5.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd249e82c21598a9a426a4e00dd7adc1d640b22445ec8545feef801d1a74c221" +checksum = "1d2df5196e37bcc87abebc0053e20787d73847bb33134a69841207dd0a47f03b" dependencies = [ - "autocfg", - "crossbeam-deque", "either", "rayon-core", ] [[package]] name = "rayon-core" -version = "1.9.2" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f51245e1e62e1f1629cbfec37b5793bbabcaeb90f30e94d2ba03564687353e4" +checksum = "4b8f95bd6966f5c87776639160a66bd8ab9895d9d4ab01ddba9fc60661aebe8d" dependencies = [ "crossbeam-channel", "crossbeam-deque", @@ -875,14 +1223,23 @@ version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" dependencies = [ - "bitflags", + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags 1.3.2", ] [[package]] name = "regex" -version = "1.5.4" +version = "1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461" +checksum = "d83f127d94bdbcda4c8cc2e50f6f84f4b611f69c902699ca385a39c3a75f9ff1" dependencies = [ "regex-syntax", ] @@ -895,26 +1252,35 @@ checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" [[package]] name = "regex-syntax" -version = "0.6.25" +version = "0.6.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" +checksum = "49b3de9ec5dc0a3417da371aab17d729997c15010e7fd24ff707773a33bddb64" [[package]] -name = "remove_dir_all" -version = "0.5.3" +name = "rustix" +version = "0.37.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +checksum = "b96e891d04aa506a6d1f318d2771bcb1c7dfda84e126660ace067c9b474bb2c0" dependencies = [ - "winapi", + "bitflags 1.3.2", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys 0.3.8", + "windows-sys 0.48.0", ] [[package]] -name = "rustc_version" -version = "0.4.0" +name = "rustix" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +checksum = "ac5ffa1efe7548069688cd7028f32591853cd7b5b756d41bcffd2353e4fc75b4" dependencies = [ - "semver", + "bitflags 2.3.3", + "errno", + "libc", + "linux-raw-sys 0.4.3", + "windows-sys 0.48.0", ] [[package]] @@ -951,46 +1317,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" [[package]] -name = "semver" -version = "1.0.6" +name = "scratch" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a3381e03edd24287172047536f20cabde766e2cd3e65e6b00fb3af51c4f38d" +checksum = "ddccb15bcce173023b3fedd9436f882a0739b8dfb45e4f6b6002bee5929f61b2" [[package]] name = "serde" -version = "1.0.137" +version = "1.0.168" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61ea8d54c77f8315140a05f4c7237403bf38b72704d031543aa1d16abbf517d1" +checksum = "d614f89548720367ded108b3c843be93f3a341e22d5674ca0dd5cd57f34926af" dependencies = [ "serde_derive", ] -[[package]] -name = "serde_cbor" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" -dependencies = [ - "half", - "serde", -] - [[package]] name = "serde_derive" -version = "1.0.137" +version = "1.0.168" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f26faba0c3959972377d3b2d306ee9f71faee9714294e41bb777f83f88578be" +checksum = "d4fe589678c688e44177da4f27152ee2d190757271dc7f1d5b6b9f68d869d641" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.23", ] [[package]] name = "serde_json" -version = "1.0.80" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f972498cf015f7c0746cac89ebe1d6ef10c293b94175a243a2d9442c163d9944" +checksum = "076066c5f1078eac5b722a31827a8832fe108bed65dfa75e233c89f8206e976c" dependencies = [ "itoa 1.0.1", "ryu", @@ -1018,6 +1374,7 @@ dependencies = [ "bytecount", "byteorder", "cfg-if", + "chrono", "counter", "criterion", "finch", @@ -1028,7 +1385,7 @@ dependencies = [ "md5", "memmap2", "murmurhash3", - "needletail", + "needletail 0.5.1", "niffler", "nohash-hasher", "num-iter", @@ -1051,12 +1408,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "stable_deref_trait" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" - [[package]] name = "static_assertions" version = "1.1.0" @@ -1065,56 +1416,72 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "syn" -version = "1.0.92" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ff7c592601f11445996a06f8ad0c27f094a58857c2f89e97974ab9235b92c52" +checksum = "4ae548ec36cf198c0ef7710d3c230987c2d6d7bd98ad6edc0274462724c585ce" dependencies = [ "proc-macro2", "quote", - "unicode-xid", + "unicode-ident", ] +[[package]] +name = "syn" +version = "2.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fb7d6d8281a51045d62b8eb3a7d1ce347b76f312af50cd3dc0af39c87c1737" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c02424087780c9b71cc96799eaeddff35af2bc513278cda5c99fc1f5d026d3c1" + [[package]] name = "tempfile" -version = "3.3.0" +version = "3.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" +checksum = "dc02fddf48964c42031a0b3fe0428320ecf3a73c401040fc0096f97794310651" dependencies = [ "cfg-if", "fastrand", - "libc", - "redox_syscall", - "remove_dir_all", - "winapi", + "redox_syscall 0.3.5", + "rustix 0.38.3", + "windows-sys 0.48.0", ] [[package]] -name = "textwrap" -version = "0.11.0" +name = "termcolor" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" dependencies = [ - "unicode-width", + "winapi-util", ] [[package]] name = "thiserror" -version = "1.0.31" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a" +checksum = "611040a08a0439f8248d1990b111c95baa9c704c805fa1f62104b39655fd7f90" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.31" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a" +checksum = "090198534930841fab3a5d1bb637cde49e339654e606195f8d9c76eeb081dc96" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.23", ] [[package]] @@ -1149,9 +1516,9 @@ dependencies = [ [[package]] name = "twox-hash" -version = "1.6.2" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ee73e6e4924fe940354b8d4d98cad5231175d615cd855b758adc658c0aac6a0" +checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" dependencies = [ "cfg-if", "rand", @@ -1160,21 +1527,33 @@ dependencies = [ [[package]] name = "typed-builder" -version = "0.10.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89851716b67b937e393b3daa8423e67ddfc4bbbf1654bcf05488e95e0828db0c" +checksum = "64cba322cb9b7bc6ca048de49e83918223f35e7a86311267013afff257004870" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.104", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unchecked-index" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eeba86d422ce181a719445e51872fa30f1f7413b62becb52e95ec91aa262d85c" +[[package]] +name = "unicode-ident" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ceab39d59e4c9499d4e5a8ee0e2735b891bb7308ac83dfb4e80cad195c9f6f3" + [[package]] name = "unicode-width" version = "0.1.9" @@ -1182,10 +1561,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" [[package]] -name = "unicode-xid" -version = "0.2.2" +name = "unindent" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" +checksum = "58ee9362deb4a96cef4d437d1ad49cffc9b9e92d202b6995674e928ce684f112" [[package]] name = "vec-collections" @@ -1218,15 +1597,15 @@ dependencies = [ [[package]] name = "wasi" -version = "0.10.2+wasi-snapshot-preview1" +version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.80" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27370197c907c55e3f1a9fbe26f44e937fe6451368324e009cba39e139dc08ad" +checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342" dependencies = [ "cfg-if", "serde", @@ -1236,24 +1615,24 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.80" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e04185bfa3a779273da532f5025e33398409573f348985af9a1cbf3774d3f4" +checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd" dependencies = [ "bumpalo", - "lazy_static", "log", + "once_cell", "proc-macro2", "quote", - "syn", + "syn 2.0.23", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.30" +version = "0.4.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f741de44b75e14c35df886aff5f1eb73aa114fa5d4d00dcd37b5e01259bf3b2" +checksum = "c02dbc21516f9f1f04f187958890d7e6026df8d16540b7ad9492bc34a67cea03" dependencies = [ "cfg-if", "js-sys", @@ -1263,9 +1642,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.80" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17cae7ff784d7e83a2fe7611cfe766ecf034111b49deb850a3dc7699c08251f5" +checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1273,28 +1652,28 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.80" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99ec0dc7a4756fffc231aab1b9f2f578d23cd391390ab27f952ae0c9b3ece20b" +checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.23", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.80" +version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d554b7f530dee5964d9a9468d95c1f8b8acae4f282807e7d27d4b03099a46744" +checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" [[package]] name = "wasm-bindgen-test" -version = "0.3.30" +version = "0.3.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4464b3f74729a25f42b1a0cd9e6a515d2f25001f3535a6cfaf35d34a4de3bab" +checksum = "6e6e302a7ea94f83a6d09e78e7dc7d9ca7b186bc2829c24a22d0753efd680671" dependencies = [ "console_error_panic_hook", "js-sys", @@ -1306,9 +1685,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-macro" -version = "0.3.30" +version = "0.3.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77c5a6f82cc6093a321ca5fb3dc9327fe51675d477b3799b4a9375bac3b7b4c" +checksum = "ecb993dd8c836930ed130e020e77d9b2e65dd0fbab1b67c790b0f5d80b11a575" dependencies = [ "proc-macro2", "quote", @@ -1316,9 +1695,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.57" +version = "0.3.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b17e741662c70c8bd24ac5c5b18de314a2c26c32bf8346ee1e6f53de919c283" +checksum = "9b85cbef8c220a6abc02aefd892dfc0fc23afb1c6a426316ec33253a3877249b" dependencies = [ "js-sys", "wasm-bindgen", @@ -1355,6 +1734,115 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-sys" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" +dependencies = [ + "windows_aarch64_msvc 0.36.1", + "windows_i686_gnu 0.36.1", + "windows_i686_msvc 0.36.1", + "windows_x86_64_gnu 0.36.1", + "windows_x86_64_msvc 0.36.1", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc 0.48.0", + "windows_i686_gnu 0.48.0", + "windows_i686_msvc 0.48.0", + "windows_x86_64_gnu 0.48.0", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc 0.48.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" + +[[package]] +name = "windows_i686_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" + +[[package]] +name = "windows_i686_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" + [[package]] name = "xz2" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index 7910a40279..5b69346a84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,3 +3,7 @@ members = [ "src/core", ] default-members = ["src/core"] +resolver = "2" + +[profile.test] +opt-level = 1 diff --git a/Makefile b/Makefile index 10d8c1ec04..4c2ef69abb 100644 --- a/Makefile +++ b/Makefile @@ -5,21 +5,20 @@ all: build .PHONY: build: .PHONY - $(PYTHON) setup.py build_ext -i + $(PYTHON) -m pip install -e . clean: - $(PYTHON) setup.py clean --all - rm -f src/sourmash/*.so + $(PYTHON) -m pip uninstall -y sourmash + rm -rf src/sourmash/_lowlevel cd doc && make clean -install: all - $(PYTHON) setup.py install +install: build dist: FORCE - $(PYTHON) setup.py sdist + $(PYTHON) -m build --sdist -test: - tox -e py38 +test: .PHONY + tox -e py39 cargo test doc: .PHONY diff --git a/README.md b/README.md index 08b7004549..9975842ac8 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ a focus on bioinformatics software. After you can install sourmash by running: ```bash -$ conda create -n sourmash_env -c conda-forge -c bioconda sourmash python=3.7 +$ conda create -n sourmash_env -c conda-forge -c bioconda sourmash $ source activate sourmash_env $ sourmash --help ``` diff --git a/asv.conf.json b/asv.conf.json index a036fd2298..4b6770bc35 100644 --- a/asv.conf.json +++ b/asv.conf.json @@ -1,7 +1,7 @@ { "version": 1, "project": "sourmash", - "project_url": "https://github.com/dib-lab/sourmash", + "project_url": "https://github.com/sourmash-bio/sourmash", "repo": ".", "branches": ["latest"], "dvcs": "git", @@ -11,8 +11,7 @@ "html_dir": ".asv/html", "build_cache_size": 8, "build_command": [ - "python -m pip install 'setuptools_scm[toml]>=4,<6' milksnake", - "python setup.py build", + "python -m pip install 'setuptools_scm[toml]>=4,<6' milksnake maturin", "PIP_NO_BUILD_ISOLATION=false python -mpip wheel --no-deps --no-index -w {build_cache_dir} {build_dir}" ] } diff --git a/binder/environment.yml b/binder/environment.yml index 2acfd6e639..eb5a74dfdb 100644 --- a/binder/environment.yml +++ b/binder/environment.yml @@ -3,10 +3,12 @@ channels: - bioconda - defaults dependencies: - - sourmash + - python>=3.9 + - sourmash>=4.8.2 - screed - matplotlib - pandas + - pip - pip: - matplotlib_venn - mmh3 diff --git a/codecov.yml b/codecov.yml index 7b2fabe42c..630c51c14c 100644 --- a/codecov.yml +++ b/codecov.yml @@ -5,4 +5,7 @@ fixes: - "sourmash::src/sourmash" ignores: -- "src/core/src/ffi" \ No newline at end of file +- "src/core/src/ffi" + +codecov: + token: 66273971-681c-44a5-b93a-f60249a2a70c diff --git a/codemeta.json b/codemeta.json index 08cddcc56c..0f92051a27 100644 --- a/codemeta.json +++ b/codemeta.json @@ -5,7 +5,7 @@ ], "identifier": "", - "codeRepository": "https://github.com/dib-lab/sourmash", + "codeRepository": "https://github.com/sourmash-bio/sourmash", "datePublished": "2016-06-07", "dateModified": "2016-06-07", "dateCreated": "2016-06-07", @@ -14,4 +14,4 @@ "license": "BSD 3-clause", "title": "sourmash", "version": "v1.0.0" -} \ No newline at end of file +} diff --git a/doc/_static/Sourmash_flow_diagrams_QC.png b/doc/_static/Sourmash_flow_diagrams_QC.png new file mode 100644 index 0000000000000000000000000000000000000000..2ce89134af95e11d7f34dd77dac258b5e5ddb334 GIT binary patch literal 40519 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%0DRZ8io| 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 literal 0 HcmV?d00001 diff --git a/doc/_static/Sourmash_flow_diagrams_search.png b/doc/_static/Sourmash_flow_diagrams_search.png new file mode 100644 index 0000000000000000000000000000000000000000..0b6affcda87679a4f3c6ff46d940b5754ead17dc GIT binary patch literal 30646 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 literal 0 HcmV?d00001 diff --git a/doc/_static/kmers-metapalette.png b/doc/_static/kmers-metapalette.png new file mode 100644 index 0000000000000000000000000000000000000000..38859ef780a8ab3a0b22f71ec3950829637ad2d7 GIT binary patch literal 203240 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≪IO9;9_cjsPCEfhe{UAG=XTJ&D z5)tL`1qlosGhpl>Z@V**s34fa=BI$5ZF_K_I^Y|ZoT$A2Z*RGBNddidyrTs=-9g*f zo|tqLY^=?y#j})U3XSBWLuUG#{zSej@uXpqw?Xkz2qxk2FmEWic;Nz{ZcA23Y?VM? ze&rQtS1R&Nh8uc&zCHg+6g2ic{ac~iwpg6zK#AlGzs+sG$p^?FVO$lS6}3R$yra@M zhgD79;NxTLf#?Mn=(2$xLEe2dM96u_SnepE_IoJ0mO!kgRf}KdqdW^}lYaO>**f1R za$N@3ZZe(Lduzv8retg$UKF@Vg*Zx37+xLns~S2OCuTl4uAy^p+VE0YxM;!MPV7^G z+U`&{%GdpJCzl(=Db|9oUhFKV5{9e{`oG_#bpDBF^uKU^7|Qt!)OCH{N6y8eGcx)& zO|q&WpgU3t&decXg+%lKxvdUT&>CkMRyjZew-0z{)rH)%;}Ow5wdqWSaYW80l>;EF zxOTIiU-nO|VFIzu+l$9Guao_RjKAd!r3&Sy$xnLor*EZb`to4_j_MB9R?L}~m;2)v zpOh*YGOA-XqmXJ0q@s#i_iIG6LGeydfqavmry}0}5O{6+y2$}N_Jnj;dOsqwq;MRr zfyC=9KqXqpT&Nd_O_ll|3|qC-(8MG+xRo#P-}s`0Uf-Msoo0DBX*nX?BbynnT*hA% z2mGJfiB6BlSNWIh0YW7kQAY3@PP01ZOgG8YKhnZO(mklI{_?75BWvFyIzn|)HHT;c zab(5M`tSAYlpD#Ar&c|Q`355rn&)0GxGZJGO$WDOwahXjxIh=e#m#W$dZwA)r6U?q z1VQ+fYIOFR7foyrN3$v#%|j-PjI3|}D_FoU|NARu;|T8EjCNPX$$1d~tqMn?%a7=T z%1J^vxU6nLAa|xsNtkQ*Aj7`#^`c{I^JS6s{HP#Jc=r-|6M1#c-`f8_;e}4>ufDe{ z_KgD)^x0CkB5v0hU`M*Jl+s=|6uB9k9w2ZBI$nzvUVV>$!N{2s%$azsEv}ZsJ>8+mh{)r z9Q6#m+RDLArfU@=F(+IETo@$`V%Sr5>Vd@?cw4x|DDak1&b&$sK#chk`pyAmLeS@p zAtK#NCNR>C6KCjD9D8f5CAS^2c7a@IMEPZ@*hcEBWeo6^C13PsYteWp@5=7r|JnA1 z0Ih`6v3btj{?_FyR|6XY`vgzxeEIG+h#$e8B2G851S@2PC9zcFlx(fnpyV`i>V`t2 zmlk2*C@i%4gf{ROU;8oD*5>(Y@>#98gTAHpAY&|}N5e2ZJbD(T4?aMj{_!Z_hk*%9 zU?YDs#&j6#@U$b!4sAcL<4S1kNno)OH0h*CFWZfRaq{<$5I@uJ`A+*A*bjm4>&-# z(#eSn1+rD}=J-11j*^8MN-+F1yr@wdI~x*w??keL=M(y?qf(=FRbwPj$5OZ29_xNuerQ(jLN-uSZ| zZO0PjG z&KzA`eGDDu>leBjP0wM%3ET@%A}oF7lh&y#rNZg$kKwGyb$!iDD4VrtU6Zat% z@4K3_t-Sf@qK(aRQzfBc?yrMy5A`0+I=jbJ_sl(27z_Vh>!xtwAo;t}3krM@PG+zQ zQ)UzrQj#}KX==lFBpBhSCmTZ^S_6%J4G~?IS#a)}5Dt}%C4cwb-6_v>-k{KJtP>`h&*BKkxNF7X)5rVppk4GL0_1mfFr*rhF?Qjw)O)->AN> z?J07JwY+fkb|M?q-uS&!lrEh3TomQFRtP=VpH`8e8pQfGZs!tHkjz>dXq) zwvTKRu!4zgI_I`qG55gQNOBaQzR|P$`G-P|_wvruZHf%;6%61dgB!@VlMD5kjyK)@`QzRTdknY9-3wU0PB(QkOYIjY(=hV zYYIQ%Y-0%l;5Q>otz*-C7V`_)t@zhYPJ&;hh>iElI$@)#`70btxpy86&F2(Gv=dDZ z)f{|rYd$=*voh*^%zbIOt9}wkm=z~ptMeoXjbH(cTT_VOHUbjk5!)2+VZUqRXx=&Q z2~8JnjZ3$QGmB*_MzD2_h%ns=kTnMGXQ~cXbUUWkaQYMPp2qg~=0jGMo`Hm0iOoO| z{`q80Dj}GGypf*LK$0G`%n{qtWquX>Y{te74g!4--JrI5^a{^egX!FRj0U4vUuJK= zS$E3wWUn{anr&J$qT{8c3_bD64J(p>TU{EzStoJkKx*x8?jv_X^MiwYI%|GE~;vmtx^e5SOE z2@Fy17TT0LY7#Nqm-h5j?QOx#+vzM9W1Wup(b`8`3Om&@!2gKBadMh6lvL$!YLB(4 z;t=yEg=v~E>0AXrj$e3m-wJ>8TAB7%@%TQ1WAZO9JDX^$JcJ*PhWl$Pc#s2cHHWLv zb*K&PBHcz{m6?0{c$o=wQljqHn?wJ(^|O9MgsQLeU7AkIF|mN&Uftu45=E7y(cd_WH=jB*Z)&yE*OoX zC}E;Tn;u{$$oj>nQhK?)#c8?qu7D%_JCVfdabvvYZ|uExzlE1Pax6MmgB{>zuZ&;5W?3wnj!f|tG85)HeOTI26v zQrWJTot}Pgb}&slr&7I(*}7K;%lgvb^9AV)`2F8!*jkBJtxvc<-BXN%Y&*yfXH8DS= z4t65B87I#3e>`kHb}oIHKQ3RXuK6!LV@oXOabv#gX8eTrh&%t&#3u;$s3VKIydWB?foKMgEuOXvBL~q>35uA5&u#(8_2p8z*q|4G|Px%kDcb2xI6@-A$PfpMot3 zP*1z%e~F+ke;>j?7^Zy^L=}?D6Zv2ddHkyC;gr`aM1d+wYEoviBobP1?M%qrTAtzv zAakeEg#5aAXrbJ;7zu>&R!asgGcAym13+wqQSe(PO~U0uR+59KhM@zA1D%&?o~xFv@=?0afkH_zj0U9OP}<4SA`*OKCG~zrVU&lvnxkn* zLsr*^#L6vy5gVq_J55hx@*`rqIPJ`8fy2P+rP z*Xcg$rN!-c;JY2Pf@+G+M~+3PQe2{vrh(scga(m4W!KS9|Bh>;?-+np;o3y*(gCiR zfjZDm>W0r;$3u5exl*%bC8BeVqJ^&CitR)39Hi~>u~NS!@x#b03oR@!tmrUx&)U|9 zTiZ?qErHmf(+AKgo&Px;aF;Dfo$onIjv8nHcJ$bB>5E*?#)7#GZ|`K8C-`w4%ZqZ+ zKqhNkMG|ZLZh*sGFcw%`q>mYfy*IZ@N#ogsTAc)~g;KRMPde;I109x#yb}APeB90t zjJQI|TZ%>IRGNks&I%>0U%q8>hfIxa30$7l^^e2Czc;Y<6$Evt3+Kd~5ufmu4{Kz}@#HgLGvpjC=?xUgd*b;?)aa5OH)teRb5hJKkRWxJs&@B=pT2sf?Xvlq=Dk z@_s#d7StnTR+BYFm_)Ah^{GVrAQe24%>X_UFu@6Bj3%^GmsHy7DsbPqiky3DGIJITAM5Kr(qQlrZ!|i+S9MNKvEtz1!GtzEk}v zGjUw)e7l)pcrcj4(7%O$hyf)W4B}dji;uy)$|_q3DyykKQ9;WWN4((7@3{?ovCvlDK zRroksoLwBBe>qdWBKI3MgJR=&hoJdjppS69p_V&=^K!VJzuow{lbvk9Lm8uoglf*~ zLFv|AU26OO-=nO}Zymc9g5_=1+F7+T#h%;J1^s2CLUnF%x06k*MdXtsLD7=z#exqA z^sGFdq2IKr2_aq`0IE7u+nuXDLm!QlOjJUfGGAdzE&6|4X1{4HaBvXGnbBI93P&@r z^^b5EsCJKlY4p2TUMR7R43kf}@DM^l!$_ep$>qzQ7jdw7WfkrTH5L15Pa|QKpsDO$ z#ph{vmyyJQymOU^jRNOkvO>!zdqr=l331G@0}UYj`LF~(OPW<8f+?*yBJ^?C7oMei z5(8NNF63IaYU(6R8DWW`W()Z%@mP|lW13zr%#}z-jPGwLgBL+c!@WgFZhyquSTbc_ z@<$-*m$r8mYP3JQ>l0r-?JnK2FEi%(J>QO1**l7FCPvpQQqCa+z&e8EkQPK&1u^=K zu30_9e+eI-*pasz#U1wYAqn*5bRG$ZOAIr zui_~@i>YUuXNYX@c6jY>n$QJ-GPS$i=(}jVsk})^$my(GNqPCdDAj<(&qT#UVD6}C zG3zm35m@#->nT;ilAwqa-{XL^&|Dlhm&B{ihk+u5C6;QMLDHJZX_~FX*LK%B6z|^q za1wE3WmIPW7E-$PM!33(!9VowtIDB^<9ymRN>YufHPqfDNCm_?!u}ycJC_mM_3V8}eUp;|PXmo2)%8qNFnJxS*)9u0aVR&vF&OWT6>9Vilc0VIMQy)+>1w zD-(Mzd2fC|SELC4MVy%WP$ETh9?a@PC0t!9J(zgN48vo}5WNcNb#_35L%P^lkN0&c zgho$;``qGJO(%ivSh&eQ)1s5J@4kXz7eqEX7!?nsavGVYsD;zZSsWHbXUfQ*M{Rhw zEfGFg=)YCvJmWD)v^meQ3mRK?DY&hN|#N$ z6^`%c{O{i&L8?J%W{k<$K^!1NKPIEjCJTiVNwO`9sMM@C3n#dN^#1oZ4Aunll z9I#h~-%j!}R<@p$?L~?x(qn5zDMUnEzBEBjyC+F_-pRxZL62<~dg@naKB~O&(YOdg z94_MXmSBP4;(X|!Mv;Vai4Syt9<=be-_Kyc!;?Zu5A;z#<@t20MeN!K_V6`ZDMIOh z%hBt?PL?-H4G|@(91p#&r(auak($830u7LcSx|VgrF+Lh z_Cc;$c5nI(^x|lWb&)UuX+AGtcgoD z0XzP;yb~Pt^H$`8kmEcwJN0nK9B?BD4)D>$3-g*^VvDye2L#WTe5dq&0nhcFfyZ-R zK9a4pvO&mL)zi2nK6Tp867JKH$R2RL`>0QncMR8S56<~C?_L)5qJA9m_x<|t{G7E(2ohc?&T+A#?!GhrU+1(@qUr5b1 z9n&|Uy?)&+2NiZn>-U0JS{S_EK7_8Y+LYUv&AUUv=xN!$H^69`W0H8;D{RY;&w`>A z1m%>6L~~j{S|e!qseG;=Wty%G{wNAwQP<|Zjzc;bnhCUbZeN3RfGlCah8R9_oOC() ztS6X(9%kOm*cg(&I>;9$p7sm`V_wy{;`)t`o}&H&+FnI@VZcr%|=0^)hO`?>INa%%!)ufTSjzR(Q+A4OsB2j z(B?JG30`cBd3ShaWke;tAaEXL+=x z(VWb*Rm2UQt-lCd?xTdXY5FHN`Y`A7&LDY3IaSiO=)UOoZ;<%8QP!Ulk78(M4LJv# ztsQ)7WDwL;1N3m_j$tEgPj*}dLbq|OD93oW(_|wfQ_RDyhlKFd1n}K@6IRtCLkO}2& zvR1cyRY>k2ZobjtHZ?V>#YQ}Mi33J!FmVkVRDOR0!nlsd-NBWb|5Kbh&W98t|8==y zhH{QxXdDtF5M!L7A{cF}$aJ+elmdQ-;BkzK^!BxJQDJN+=V|&$sU|8{gPkcQ>dh12vi(VO2x|`C5+HQ zHMfE3*j#wwVxRwZ3BEmwZI2T`W}yAT+vL?m&u_-Bw^_AaNDY7UMIYTdCfA|Pl1%*8 z-day2!BsenbcQ05w2BeY?AP4Yx)y_;tZ@7j_WGm(e)Ni}==svs1qtb61&;)`U3(wk zpg#rb1DNMb^w;gDS%KS`fywy2ICk<3a98NMDhiVJFnrg=$78t4y|GYp)b)3(h2u|Et*6LPWhx0VK_0 zW!AXNUR;}QT<^0oMqF4a$QsIDNKRG|rp4ScX7`hsdRNQv%m&%MqHkCAa|v~c<7c^% zBXO~%KhT&V9M91e^UH)Gg|_h6Anp6*BVOD4&P14p=%O4Eb%$|ESK>1 z*hNATldJGfR5Hz0)>!`2B^0G|6G7+()$KCFWQE$BtewUx!^byn0xt}QTD#bn39;_d z)>J~*KA8)JDHeQGJp<$CmNM^&ScQ4h(B~wpnA0iIJ1?aV@%5TI!8Q9ELUv8 z^npW5y!v_2BY8JRWvHOm1ga$5Cgj$Q>i}q#ExUhQmw5ZG*P3!C(Au6HMksp0+VxG% zyR-|^sn?(MIwzVLfl?7rr0R~X92R_u`W=8fA^6F;1g z{c`;=!?zUo-cu-&hw7|8NCjPaT>iBfQH(c(l6pGHL6#7MX+Ttc?Aaerbp<|vTpofD zw{}L96rlNw<4D^R=fK*8ON2)*Up4;9sTG2%xFWddIZWSE#Fu$0u^*9n(DDpq~2vlK=5Bps8 zaZ*5i&RoNSe4!Qce)P{{>5q1#m1GV>VO@a_<@SN9)S^ZJn7~gK|^Tk@5OXU~k_Pr_8BXV(h!PkQsX}s(!vsQ_=|JUmk zrAcH!!fvGFmk5~{9&YpO&9-tW-!F%9bSC*to_|DaO8g#pbs?r4Ema18Wej+2wtVc$ zg-KM-N)pYt`r|M5n z+VDTmC>(>m4(r{I6|@IHiaiCyHLGp9VNEQHTaDCyF9~D2&N-+32m4|^JI0^Mc*TE+ zvwC#gW+<+M=ep2gkP*d@a$6Hek$;tFi^S7PVpSiJoj zB+W-qj$2^S%(%rGjopIQ^_;kHU5Yi&8C>pkL{rdk(R@~1(`vD!8qvjV=nHX+ zZVvjX+MKy47_R-#K=D&rC-M<+!TG&JMV!)z^4%~7;}n|qnsVrFV6~@ZIm|ds#h;BR zgOtnNJ1&k~|$VJxx9eMEVWJTJCd z$p2dd648agZy`+Le~fJpKWPFi2IWcvEzf*}Zk+CtU}vSLs)=?YRZvRjUq@BHs(8Gj z=f@`;9Gyd|g>4=v(Jq$*6%WFHe;S)l-x6R8qUgplFvka>(cx4LSoK@MLL7>=aoH2t zQtlf_fKS_g+!+d9>jXD_Jhmr-YHA=O=fGhbl3Yj2A@uljng`FG;G_;Iv|PK3f8=A2 z|Nn@32ZzYRw*7mW>~^zjH`{ibHg4@^x4FqSw%Tmlwb?c%+qNg0Z_o4I_w)M~<~!$g z>Nr0~6`kUmO?BPW)yqre{%1O2x1#qu8zpN0##z*?D^YD8Z`J`;bwg#!iAO#Zl&hRtWf7Vl%tjyKW6)N4aqG+4ssi zG+Lt_N&od9*b!{RBI?>t46^%JBgMHOQpKc>xp||#&TBNrQS6IM1fn^pa1ujZ8@-3$D_i`sYP`Rc{VeS(gwMmp$bWT`(klcJd4>~ zD;Z%*t$F31(YH6Xfrqd%N&M*AK=f_mM*u+#&QypYf{+x@EDN7ApkcHC);fYaTUah@ z85x-1Y#6>`+7) zE%qqh%Qy6Z#ixU$^LU=d-T&YN5H2l(!}_rjp(Znk!oF?^Yyd%dp~lGFO?Ql8J%>^f zKsboM3up;Vh1hJTywt^+;py^tVQmx(sd>2Jdk7%eJPD-YGBKHOH7Zr$kZ6!Hkix}S zaqXL|%dzz8JDclV2cBeARe*LWk&Uws8gDT0HU8@UZGaWa3zWL;K+1=5btZmtg4L6q z5RM32qGJM{!YU_@{wrEHDKBDmyvl|JHSlL$i)io%T zvsvW(7iGeOv=ouziDY!*cgr#!LWp_P9)qo=zprP!7Yj@<3E~-y6O$afka@!x(q?Hk zVuQ5~pDe!29bK3q??kq!fkiGT?%K*P8BZf^h)Dr+SF25fScqe1zei~Ei&WuP5>Sl+ z9Hio`@RfSe5`J(@B#UJgu1h!4YsC4XCH0hDs@6|lZq+F6Z=Os`DV3)$Up)tFdJw*4 zjWw?7KG|S{r?JU65bJnYJ9xQYgj>Z=2nyEN)f*m(jT?~UOU3A85m*a*%B*YGl9#*bCRJBEfSTk-G|FRoU=M; z^ed#P>GySQUwR&CeMX%nIjbf(i4U#&@Ia}O(Y5kPJYSgq2%$J;7T*~12R`6a<7wm9 z4Gy=O5c``xE%!SEdDWwCU8)wwU?gZybIvXHTTgWAa8hdZicaZB=#c3Y>`-Mi{}X2d z`IMW4LFD#R8(7_0H#n&q`%TZajO%(KV%&PN!|fNET8quXf$OHrj|&@Kh4h&5pPO1? zLW}%XoV*XtXuWA!=PM2bjOBI1`)Qt5GbyAM8PL_XKW8sZpZwW%%%9KcfN_&uE+BIb#|0zF zw+Xf;sjiDcrolgiwcgn5n|!n&@`SoTDcq{Ad}K=%%XaHVdpN8I+9&p*Z8&0S?Nw&a ztNf;i>9C#JB2Qk`N_Bj#AQ#F_k1hH7MUHxEZ+nj%KkG?FQdi3s&y?>*1k7cjsy)Mp?ee&6JX~-{xxajiznV*`5&CEV7dJP{2BJ!#Y z2nlq<<7cM?Y|@A$2&G^PL3VEt`8EaxvHQs$SYI#QfkY;KW($K{0GX3KMXs;M$F;^g zA8qxonL)!;g!?$=Yxft^rq zhCr0-e!rS_lZI}@h=gYBH-#QbyU$aIcxuk_qjj#Zr#TBW9u2dYc_39INVC6CpTd6! zBp8W+UV0;=OKwo8a38DT71}hqNDhnrEQ93RD|S*zSS(|nYe328={k_*MYy>ecF~QC z;7aP%8zc$v^fpu~_qw436ru+q=KuRNPY%bGD8c%o{aFoQmQ5oftNF+CH!jw{)?d#a zjHSC3Fw!}#yWGL@hRCy9A!L}FfrSgMq#_qTmj6X0mPFT$pQ-^;Td*MoY|XW(!{B2A zvT*pc@~Fyrii@8ahhGzoqr({b7jnL$lBQ#n@k3QP31!z}8dZNrCM@c4x?3Q4+lhO} zdEb%m#QcaEM3HyT`-;QUz%`w;&@62U^h*09qIrf>;L9H_$S)PgV_YYW%YVfOLRj^P zG%JY&T^w#!VmkuCULw3pz~>*n4XK-RT_ZlE_{gV?)gqWG)_A?|_iISnuPuAuHqBUt zu5`%F5|dvq;U|r+$$v0TPc;vHAGS2AF)AkSZ2<;6vF-(4f7$EuCUtYvoq-E(PYd+a zWo6V(`-vW?wdg&Ziz*jtuimSmSVI=^D4P1#ac-iC?1;Xcu$mLCwt1Qab)NRAVvNW5 zgVtFA#_qA;6Oi{LEd2CrUV}^z&dCV3&1n&RYe#SiMV#c&J{w<+jIVuB`RHOkp4s4H z7(6XZ(A1f%i_z+ho~X-pJK5tD)XXcd9I0TYVnG;FT(vUB4 zo9bhV^3`EeFTb<&Nzrw^?>sN=dRQ-rVYM2f+O$4BbNqdhab*xFG?H0G#CpZ18O!HgLDMgtE()+EnjSI~)zX7is_XC^$Sqdabq5#!=(8>*=(+zmRm_xc;dML>+#gMbya&K6kmOO4W0F8^N z=n-(<5RKm(L2$Pv(!a-^CMC+|JYp|DW%pQ{lO3CTs=+wkU}gF7OLk&sw;5jom8eBd zSew^m&Ybpyy~8LUvKS4fRlFiPWNJ+|PHceimdANcyKSc_&oDmWkKWV36JE}#xQ6z_ z%^mh&1Ib28KLv?fM!aj3hAD5=ep>Tt;{Udq(BE`qL^ujIejXHUEt*h`WqlDNtJc@u9#BGZ zgwa4RJm0UhW&7hr*;8nUOYj}JSB_QD2L6w;3xFJII0Cd{=)N+)2>Om%X5@~L69}8at;#s+`6vPw7{K=B3y@OuBs!&lL z?_>}0E+aE*YRlSc!WZ_^U0IyH{sgEPgg1&1iN(0knft`iX*K3immqYTHLoo z1ikDS8uGHHmq??Iwc&UQKbi^q>Tr&z)6+2OkNPdI<$0!>125P-BkdHf&k{*dzsy>D zIr#nb`Zwx_k`l1jcAqV}5HiZ(HK1nL!kn&NZ_pFjSsfdi`CbRTqoK${rsG5XNp>op zRIrKy?Hpfj{#Wd%GVwm3O~v&8I@;`Lk;~;2pGO9Sem}NO)R}w3feFJEf~@=N=2mOu ztFO#0k2O~f#k;}|G)&l($hbADk3PSEM^I3U_@9f7Ewwcn26u`F<|ORc)L`2(F`Js3 zCSW~yK76mpGDYveAsgS3sz?sPq^7-O@2jm)OTH^cOMU-qz)mnt8UX@_AgYqk0MaE* zEDdAu7ZuN{$(Fb@I=Y=xR{{eu;CS@ZhiJa%TXlJ1hW0A6p@{7!vd@b^Yud~k-X8y| z_6CnP?M{bXI`RyOHJf-|vQyV4+p=}|TsH2!s3=Cg6^s~JxYGPGO96V3danF{*KYL@ z2k|)P9IgEcz;E8h?gC9600sE_j)q%5nw?;Ps|T$<;bZU;i5xk+{dewzv`aA+=);OX zXOjzvW6*J4!`aXx9_+^#%?+#-GgbnhJ>B^SJA_tH$>xPPRppzMEJj>@%>TMb=7zH6 zf2>o=sq|1^ZInl}QwWKy_5pcOaOE74l8>P+SErs-iB*PDSV+Y&;gO1WxY*tz5iUTK z1eXo53%Q@n^oUQ$Jpd0a_BOrW5k@#>gew<_1}b$>nmp?84|Jcd@%%d1;AliOG;AatGvg(ACSeX6u9|jvfPRZcP5=#Hy-8nZ1N9Y91&~cQL z4BX!xtd4Q~oXp9$*c60PujyqvvAgOrMvv_Bvv-Hn9F<8J8aa~-zdTT8-Ij8;hoq}1 z8aXCUDhjgu3NZ-h-OgDNp>rLf?5BZqCDKALYo6FMXHI9(oKpdZw8MhY93xOR_Fnai zWmI0zv*YR!w-G7|f|Rcv7ek7D?+*~QUip;BNI{S7Jiwt;q`?sIfKKHCJGMXik!ur< z!u)&rEj-`=PIzRVB0V7HAj7e^x}j3=M*Iv(7i>hV?@DQh_RD3mF>le0;ZahWH>jwG zf3d>9#tu-6PGsVMnhIF|OL~o}FSys9T4y<_bZ)4Zc}Z5BY0WkyQY!zrvefC-viUPE zV)Lc|Dxo(pV|jF*MVFn)Vjim6Tb3+y>QsN(2~Wh6cA+kbt$kdpM7HxU(~S|`=QV9< z`7=h*F_cID6M4cW-{Y~dulK|_f3s?V*{~Z3G;o(Qx)ni5EIa}M%&lLOhTLDf-N^xM9cRlQF8D{#5_8wch z<>56g_Ays3&vq(TgMngfY->*q(1oWx8>c+}&iWdF`yKGQIdqkyHvQ86u(=+4uNN*v z&u#`p1ukv>plk5|0af?=V?{vSe&q2eumNRAxYMl@Jbc^V-fp)kr>G6}@boE>9z|#v znE)b@QKyhM?Oh`WO=DKrY z*_eJ7lUqq^qR87;lkEa{^gas)EvqG=&stK{)&1eBI{2*n8l5;|a3%6-Yr3t5n{y~+ zlQ=2U6#mFuPVm;x*?-7t3)0;vGJ)tuEV^J$65p?{m=$2BuX!QoIy)_8x=T1VUU#89 zR*0HsYY`8d{dv`1{t|x4%cb^l2-&n;jJBu!r+S2|7jrs`u5e!zw~cF9G=f!9w+DKD zX5%U##dI}i`!AZj|F^|0fH^}mMo!_8eu`LVWIw-{$F+#be$YaBaH0Q}5cr`JSj5?o zWwf!;cCW5ayd^vPsG>!&_Qe~EJzd@xq`OSfUVi?0ENr?gDpZ#JzC7r%c2saVD!*8*IzF!(TmZ|OFOSJ>#P5$fSdd03;=lbEuXor8^IgW*1^hdsAv#0 zDrED*i7X5RRHjmA>R@U?qr9Pl)6>O=GJXaL?CfzVZZ5Ta z*!*4cU0}GPvXDIJo9i`elt2$^<0U!9(q=gA>kEm0P|>?{ZmabArl|3&D6( znV`7C**O@^2Dk6gsf0YDxYQihBB5)f5Lg-H3i%q%`KzcrnYx$8&*#jyr7@#wa>{h2 z^oRRv%p4*r%Z}M6m+l|P_MkkY^6?>~_6FfdLfrPLiq39eW~!CWJ(wfG=<&t>d}lywgn7J!LAZFwWu_I!b>+xp0*azWrZ4wQ2oX z`Z(>T#>f`?<*BAQX|wgRf?Vn$@^`1JT?0k6_V#(+Y9h+LrAsGNUG~K0hyc{?qci>j zQnI{hDiT4*yz9)K!+@Hq`8xjH&YE9+hGu~pcgBFUi9SQ z4B;B;&GW0zciKzuGWi@)k^U=Znz8tI=iqU-5ZOb$EkYs_r7-eVp)JZZ6pKy^)(Uzu zhsp}g&wrMvMkq?s?VKg(0uoFum=s94`Q)y4MO9|xT_=8+3?!i5HD?zekJ*P^VZ?E( zgQt3EE@K%7T#5_hsM&aa283qL;`wiof1slgZiNAgOX;)8(f7f{`jpL6T`2b9(*& zTcbHqkxu&iX3#6#q$)ibRvqI0@@LZ;GX-G4HRL-+fFn@BZ+km>haE$Xpi!?%tAW1W zyhHcIN>JB~{oq`&3dN_P8R;Qqiiyd1k-6I{Qct_^)@?m{=D^;*QzZ=we&AeIA#7yy zam-XpfJxj=(90}8kEwrm2`lTeu{vwFpFKa`H(o|IorLzw>apjCCmb@8&uFbv!LbBU z5Eh-p=ZmiL!?Vyeh5^D2x8P4Mh^2sep;F9sLJskAGi(gT@~*4)CkL~-`aXlNeHr4b z!cdbOH!j;vsmG_H_Srr2>H5sPBY zYl4DyuM6Xmn=V%e!q}B>w`v245ps+jSk(p&)npZB<}nj(vaQdC_k#$X?XM6MKSLi> z+?Dj;Lto5KA`Gbq%$s{rbN+$)eo0z9E>!!QEYPtP62i5iih7E>x6Pg&lrZ2}gbZG% zyn^N~3=JieT-n$2&6Mc-vfoHD*lfz)5Q=4}#%29fB;U1=kVGzRqC>|W)wlKRbX@=2 zk%)$L6UUs04L;q7C~fuloo1xe%ZBEoMZvzJXrZJO0v02>*`W`=&}A&$XQkU*ESZaX`{<8U^}HP@>zgPPguY;{w;C>O;Uh+_p{FYt&9lOMX7S zN$&{o?Cj4n_3AHsKAU6A_9jZ+I}zLijUBh;59J$Aj*E%cpb%gTs__A@8eznEb|Lozsq5&c(tBe)zxg_fwpGy%?jHQ4K$SgGh5^6;m&YA)Tyb~ctk-R2Qf79O9{f+5bLqbAkgXT)rHngtv#+x5K<{Mts zf0}6o2^5_)Im))yF@87g+^K|gTGU>AhA2ztp{Y8YcGb{bu zJhKgo0{`fBpO2vp&F6B?+3kX}heQJuI*X+i?K% zp)(MHDb`fJg^g5?Gv8;~irHXO!y-(4`4p`(6Nj7U1348gC;QmU^RVLhQ<5tb`%|$; z4DIjDv>nGA4QGPnry|P_XF9IVe{9(CcFJNlaxWJdmQ9gtv^lbMI6o@A^z*;7WWL@U zNI_2oZ3vvG*g=+r|8hdV9%Zg{Ijs-!u%H5TQ9E7Xt()GrNV9N*N!|55w`$e;0&y#& zf^UsxQe066`{_*3fAP(1C1EcA%yar$oOptgNH3bP=BS+^a?Bj(yH>XW>vzH;&J6}M zA=2YnOw-4+Lw@JEaIN3NfqqR494O+4p6>|O#F^&$0o-T41%&Ve@xHmvA1uI9a|bC@ zv^%DMT4oM@5?LibHZyjvpl>ihe?o-+xs6(>PExxQ!Lt)@V8%Qcw}GKYN47=b%Ur3k z$z&IZ7l|~P1#oWB@4r=k-nvKkG5-IR5+BVD4&rFkkVNJWSZcB} zdKt=kkr6m|n~()O-L2R}34gGPWJP`fGOp;=-x?FwG)=5cg?z?17y3wFx=H(F^`53- zvO&)_?{_xTO0UUa0Ta=gbfg2taU0=`pET?%4ok*K{k2)Ir;8n49xs=q?{>f`;330K zy0DE(8#m_r+P!Itp5RI8Ws%Qak=BF+$A7eJ7x?@5i_NGl4TgDnL;bTq!mgj&-2RWYSN-p4RU}*uw@W^ zyA1tR*Rw}^wcD`K#fNR%E|@t=t#eyrs(Bk=Rv$OiJP?%_*>`llH+>`KQQVyYSAY&I zcZ3&&XO0;?t%u?fT8CA5*7o31ukuenj$vU(*vm_ItKeC*1m@Gh`i;-lJvP!89|{ z9dEg3h%{>9ZGXnl@uZx!-cEM^65QgbTj_bzF=@p6-t`;M-_m9;&tcA@MM7rgL47$ENN1@6*G9lkHWkssz}$384xc zct`G?w(B8*@TKO+%w&-SbR*uOZ)h!!#7=$dnHItDixvAM)x;_S;zocAM2@@Bdkv*x zRia_Uab?{HlnOGeIMOWtBYrl;nTG8EKb8$21@H?}0?CMD3{4F}Ux61VZ3(4g)87<9R~A;?7dv|v;z<4O>PYLWh1Hq znh~cZMVSOPo;!r%0IS2TxM~rLu~g0}Da`;dzP2$LsLedqEzbB@l&FYzc?~yrZxZ+z%QjC;HlqGdV=n1@)F=_FWFs}9TYA=86F>rV`odh9 zd2hjRcG6R(kDdjGC;t%BV8+;aD)5$--sRf$P zebWj6EPr6fuD0s;a|U|p3j(7YzgAt2^lj4JjM#GI?>Mrhlze@Ok2G>v!nv%*#5#A& z7{EG!$b8Cob9J(UtS@>t{Ab#~nL`I9)i?jc&im$D3gxRp{r8ilR@tE+P8&H&;*ah% zqwU>SXS@sRj_8i(x=@aATGo2c%L@agdauVY0Ot0akEd{M6zLDFGq8JV>IcVfS5%dN zKE>3aI<)$^Ozw=Xi+*NrJAwP=i3m#B#MMBxQ(1G*aHr?CKVx5w$rGr*lc^WRBbZ~s z`8C=3_s>Uno5DFiwt>%9sJiQqPSe0DDrRr$G$!pqt3|3zwU-u4S$B0s1w(%hQmuu1D7fxVetQ<_(GmhL?Jb^0rX!$vpM-mxjk47zpSHDNF zR${`j;gUPnVNV8gR_ji*kNtg(`Sv-pd<0q%P{V0U~S7Dl)G4CO22m%46|N z4$kD}MfXjd3wO8#@$HAFzl{Y}q=(QSfz)b=ONr8Fw_(+JRihgS`-CI06R6l@cpv(^ zJ48>sEb{pis402XZ<+XEB7`nl4hSLv6J{+1s#pti?rXJ;vq^Wvnusqk@Cyp<#Ou98 zxFGuJ34^$n7DGtscE`wZsM38?nm}4Gs z``@v4{AXJ^rh(=58X8F%u$oUGcTcmd8HLCbop&Bk6G`$IJg#w^Svs@?o#-n>XIy?!j@HH~Fpy{2TZSdOlvUQy&M7cYVD$UEhEG zV4D6`b{?YWX&AefNBU}(mhNx7ZyC39W=XHkUN=kz#k1{@&gKX-kdA}yMs(T>5#zhe zdXXwci3ULlnx!u(xYUWe0A|IIi?gS1sge@-(H^FLrjp!s{Z3C?*tPGd)j?%&49}F3 zSm+`Zqf^a$!u2I9#@1QYOEAF6hARCxvbBbkZYzcQ6IaCip91$3aF5rQLxqq=CosHD zYc*y#$Z+yG*-Y=Ud4R_{pIGCR3XfpZurAWJ;-U_-l%*P(RIz8~a1wzPF79y!HpUbM9Ew{4#cM zi`8hla|1cRVtUaZ!I$AI^JA*WnVclc|0val=+m3+aiyiTN7NjGl588$zoWh!FeL`$ z+GR=1t@ZhR_pek)mlg#fUQ8(IMqHh|$jOHBR%!^s>ry=lCYPN+@!UriNfe=8ac@dD z+T}S;+kmVW9gl=+5!4_3n=e0yFxrjeV|C(;%KGK~pMJPYhca49T56vt)`Es~b!1+k z{82M*eWiKc5&g{D9ra8x21xA}(~av{&FIWF$)+%NsP}<-2nL-0{TlffEOb|mNP~<3 zTLT=uJy`&^e*8lh15y|QL~fYhwwaYP4+PI2K4{7Q$V}3!#!#hh+Mf4nKDTPs9`|Q` zqC#7LFhBOGH;Oyrq)$qk>ZZKqkPDsw0nSF?m}16FiR+2hMyeUL#UD^`81dX2(*7Pr z=i``-0x~)Kp|19RdE+{P&%KLJ((?Mk3NGp6pwxB`GP5=yV&7qF8(#Q9)Yq8v>;vQE z`VpaN%Qk6N!J=qQmv_AqNtJG6mfK~ur35fS9Iri$cB2P_@2(T)p(&vwRVJhBNWu#Vhc2m`^5`&B??G8Oxoezt)7j zf=7u-<lb18)G|_2?PA*g=TvaNtpyF|Cadfl zl}N1$GO7TzgKt^nPcDLmZ+JKYBuF%#1l-3zN=|uaQ?C39@XaD5$I1Xss*jhJ(9zH6 z22_xu=&3?}a%tf!#AQd8-0}IW4Mlgetcl`Iy3IM3gQ;UH?Y>CL&( z!C)tN_VD{DQLkG?ITfnP-yj8X>4n2!AC#!nR7rY4)}_cfjOBUBjoCJTYQ%WnYC0-O z^pGP>Q77e{^KT|xc0Xo;3)x~91wkL!`erT@tOz^TEriLyjG1^|xIGC6pJpEwTW}Gd zsUdn8v`d`#N~TE-dpj`DAub_vM z0r0n3rAd)bddQ36#k9xB)MWFUq9XGaU!`1w7=c{hi~nZ*rDc$DT4@}QjvF&rW0Yi> zW!S-7xaCvBf+P*ZB}D95{{PKa^fHa?XF1=v&4kK&Ae0bN(g?rbp?#{4H zNk%(6g75hs#Mnk{?oybc`f>;4S0* zIzwjUH5a#?X0F;9vb^abhLmf$7x`xW!et#8v_;w|lmCa$gzVfB6A=8X!C|Fr67(H`P@!QbM9h2h|> zthb}Ap1WmgKW{V*XN&3y!TZ(xIiHsu(kz8m^~=GpQQ;~Iyp*%yrlBESTzb9Qkt<&P zd)LcCnFXL>1A;i~VK6g{CeFv%a#}2$9;mf@D2{wYm{`D9c)zoe3&J=pCC)H?5+FPE-HT4)}7_J^85F=ZR-mjc1!zXTX{$TS1tmZ@+nlhBP{oZ;_# z1re3LsLBeklrR^uj#)jSEgBsI--`V;&yy)&iN@2yjL2|Ermop(`SRu$@#S6KLjS

>?iE~TX$^6u> z{?D+f8ors4WxN@O1d)Oy0w@ue$8Vx?b*NxQ)MEU)4#dBZ-(qSTM!A0-I)wfpwh|z} zWE+e!cDqZF?%y!w)RBTy=b}Qc6@|Ggn)d5>F1_@^JC{4T6vn2seL6le zx%lUMbD;iG%rGGYiF|nL|9Ux-MHfSd#o$;!Y4E4Kc2IWn1ENY539=(Yrro$n&NEyR zvC(#fOzZ@!x8aEgw535ASAb~SZd25`_&?1Zx!#mri@_HrwkI+-vBu7$C?bRq-s#?Z(_f< zK{OOYiH+jyo{Se*{p_uFHTnGX#mK^~i!%Q&r-&p1pe>LhlO7KcAm<(aZetEM-F{iu z;k}*Vww-{RZ8+oTxJDpH38IBr0TCWE_ed09Nthy<|AArSL zi&NjLXs)!GjCg90Ds_-EdOsx1M$y<5wZRzAp8tv~;E~z;GAu+gM^!8JTO{tqW9v>N ze=Oyv$9el$2-t8leI=tiC)MQS(*;uE913!Ff%?N2!AL}3a?6?-C?ES3@7Dz%K|j+8 zOAqm}((PmWS&2Zfg*`kmn z2#HJVe`EyntbAojbk61C4*>1#Ry|**4p-jMcezu+B-kZ`h5V?Ih%2+!sE;>z?2rw%nJ z&o@(-ytz$T04ZD3avSu~BUH4%wKN4~8OZzCc%C1`y;#eG%YD!`PkOANHGwfb`7&li zA4-IQ@D+A6ygxWU7Uj=W;xtOZp_!3QyH{Z|6ZVNWPPy&OI2}u>!El_@wKuY)?swB} z4P=_|fGVV9Y}h$#Y;>y8P9$sx2m`QCacigz*hm7;ZmWMzz#$_`Hss> zO52e62zNbI2Q!2e8MRghT<^%z+v$`GuDfr8>VHqLx<1^P zA_LL7!dp9*koh&tGaJ<@)wRbnuzfa#Vl#!$3Toq09PDc-}S|LR8=hH>+N`3Er{CO60fm{*AXvTi@Z7~+v$|IeRN zw4zH<|G2~0%t$k$j7%-^YG8${@JfDz0Eg5xJ7b4&gzKNYli$XNQFxYCx4Ofu2t%YEC^8e_fgxGjiVJ5p;S}3769B{Rz17Xp~erf_1EQc5;4ZYi>GPZc|N2%z2BDIbrw1`c6vx07oGpx#vLmh zR+k_Ii*z9ci{l|w#WWfUo{GRqc zyLD;5_F@0Q&o|KM!(Ql;ju?^b0X(2(S0T2$O(^PXtZbv2FX#W#@fcwvtv^mvtW zIa8VbDFB_HA_!_KO-gE8fx%@~w4L2HnmcN&?|I`M&Z?|3^!;J<@$%MY9jmn15{c2V zHO}u5ZaimoL7|r54%xbcoUfH{`=B>2} zV4_v?Kv4UhfvT)x!}A6og~@>APPpR5E!2`+_ZnViazF8-m3E^g z;Z>GQhf(}_kYGK}{S|#O%oKgvL|+k_*+RSlyceTIBkcdhjT-Qo02%pb9Gt?yo)~I> z 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 yjdQfU`CtL8x^6gFCWxPWp_@bME)J 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 z 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≫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%0AR7Tfo;a~Ni9NM6KXqMiL+ZqyP0*qjqCBpD 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 literal 0 HcmV?d00001 diff --git a/doc/api-example.md b/doc/api-example.md index f042d1a552..782ca86c07 100644 --- a/doc/api-example.md +++ b/doc/api-example.md @@ -241,7 +241,7 @@ ValueError: invalid DNA character in input k-mer: NTGCGAGTGTTGAAGTTCGGCGGTACATCA For protein sequences, sourmash does not currently do any invalid character detection; k-mers are hashed as they are, and can only be matched by an identical k-mer (with the same invalid character). -(Please [file an issue](https://github.com/dib-lab/sourmash/issues) if +(Please [file an issue](https://github.com/sourmash-bio/sourmash/issues) if you'd like us to change this!) ``` >>> K = 7 diff --git a/doc/classifying-signatures.md b/doc/classifying-signatures.md index 8ceef47643..868ef6f7c8 100644 --- a/doc/classifying-signatures.md +++ b/doc/classifying-signatures.md @@ -214,7 +214,10 @@ The output below is the CSV output for a fictional metagenome. The first column, `f_unique_to_query`, is the fraction of the database match that is _unique_ with respect to the original query. It will -always decrease as you get more matches. +always decrease as you get more matches. The sum of +`f_unique_to_query` across all rows is what is reported in by gather +as the fraction of query k-mers hit by the recovered matches +(unweighted) and should never be greater than 1! The second column, `f_match_orig`, is how much of the match is in the _original_ query. For this fictional metagenome, each match is @@ -236,6 +239,9 @@ f_unique_to_query f_match_orig f_match f_orig_query 0.10709413369713507 1.0 1.0 0.10709413369713507 0.10368349249658936 1.0 0.3134020618556701 0.33083219645293316 ``` +Where there are overlapping matches (e.g. to multiple +*E. coli* species in a gut metagenome) the overlaps will be represented +multiple times in this column. A few quick notes for the algorithmic folk out there -- diff --git a/doc/command-line.md b/doc/command-line.md index d625810674..12f6a9005c 100644 --- a/doc/command-line.md +++ b/doc/command-line.md @@ -49,7 +49,7 @@ Finally, plot a dendrogram: ``` sourmash plot cmp.dist --labels ``` This will output three files, `cmp.dist.dendro.png`, `cmp.dist.matrix.png`, and `cmp.dist.hist.png`, containing a clustering & dendrogram of the sequences, a similarity matrix and -heatmap, and a histogram of the pairwise distances between the three +heatmap, and a histogram of the pairwise similarities between the three genomes. Matrix: @@ -75,8 +75,8 @@ There are seven main subcommands: `sketch`, `compare`, `plot`, [the tutorial](tutorials.md) for a walkthrough of these commands. * `sketch` creates signatures. -* `compare` compares signatures and builds a distance matrix. -* `plot` plots distance matrices created by `compare`. +* `compare` compares signatures and builds a similarity matrix. +* `plot` plots similarity matrices created by `compare`. * `search` finds matches to a query signature in a collection of signatures. * `gather` finds the best reference genomes for a metagenome, using the provided collection of signatures. * `index` builds a fast index for many (thousands) of signatures. @@ -91,6 +91,9 @@ information; these are grouped under the `sourmash tax` and * `tax metagenome` - summarize metagenome gather results at each taxonomic rank. * `tax genome` - summarize single-genome gather results and report most likely classification. * `tax annotate` - annotate gather results with lineage information (no summarization or classification). +* `tax prepare` - prepare and/or combine taxonomy files. +* `tax grep` - subset taxonomies and create picklists based on taxonomy string matches. +* `tax summarize` - print summary information (counts of lineages) for a taxonomy lineages file or database. `sourmash lca` commands: @@ -168,7 +171,7 @@ ______ Usage: ``` -sourmash compute filename [ filename2 ... ] +sourmash compute [ ... ] ``` Optional arguments: ``` @@ -190,40 +193,61 @@ The `compare` subcommand compares one or more signatures (if signatures are created with `-p abund`) the [angular similarity](https://en.wikipedia.org/wiki/Cosine_similarity#Angular_distance_and_similarity). -The default output -is a text display of a similarity matrix where each entry `[i, j]` -contains the estimated Jaccard index between input signature `i` and -input signature `j`. The output matrix can be saved to a file -with `--output` and used with the `sourmash plot` subcommand (or loaded -with `numpy.load(...)`. Using `--csv` will output a CSV file that can -be loaded into other languages than Python, such as R. +The default output is a text display of a similarity matrix where each +entry `[i, j]` contains the estimated Jaccard index between input +signature `i` and input signature `j`. The output matrix can be saved +to a numpy binary file with `--output ` and used with the +`sourmash plot` subcommand (or loaded with `numpy.load(...)`. Using +`--csv ` will output a CSV file that can be loaded into +other languages than Python, such as R. + +As of sourmash 4.4.0, `compare` also supports Average Nucleotide +Identity (ANI) estimates instead of Jaccard or containment index; use +`--ani` to enable this. Usage: ``` -sourmash compare file1.sig [ file2.sig ... ] +sourmash compare [ ... ] ``` Options: -* `--output` -- save the distance matrix to this file (as a numpy binary matrix) -* `--ksize` -- do the comparisons at this k-mer size. +* `--output ` -- save the output matrix to this file, as a numpy binary matrix. +* `--csv ` -- save the output matrix to this file in CSV format. +* `--distance-matrix` -- create and output a distance matrix, instead of a similarity matrix. +* `--ksize ` -- do the comparisons at this k-mer size. * `--containment` -- calculate containment instead of similarity; `C(i, j) = size(i intersection j) / size(i)` -* `--from-file` -- append the list of files in this text file to the input - signatures. +* `--ani` -- output estimates of Average Nucleotide Identity (ANI) instead of Jaccard similarity or containment. +* `--from-file ` -- append the list of files in this text file to the input signatures. * `--ignore-abundance` -- ignore abundances in signatures. -* `--picklist` -- select a subset of signatures with [a picklist](#using-picklists-to-subset-large-collections-of-signatures) +* `--picklist ::` -- select a subset of signatures with [a picklist](#using-picklists-to-subset-large-collections-of-signatures) +* `--csv ` -- save the output matrix in CSV format. + +**Note:** compare by default produces a symmetric similarity matrix +that can be used for clustering in downstream tasks. With `--containment`, +however, this matrix is no longer symmetric and cannot formally be +used for clustering. + +The containment matrix is organized such that the value in row A for column B is the containment of the B'th sketch in the A'th sketch, i.e. + +``` +C(A, B) = B.contained_by(A) +``` -**Note:** compare by default produces a symmetric similarity matrix that can be used as an input to clustering. With `--containment`, however, this matrix is no longer symmetric and cannot formally be used for clustering. +**Note:** The ANI estimate will be calculated based on Jaccard similarity +by default; however, if `--containment`, `--max-containment`, or `--avg-containment` is +specified, those values will be used instead. With `--containment --ani`, the +ANI output matrix will be asymmetric as discussed above. ### `sourmash plot` - cluster and visualize comparisons of many signatures The `plot` subcommand produces two plots -- a dendrogram and a -dendrogram+matrix -- from a distance matrix created by `sourmash compare +dendrogram+matrix -- from a matrix created by `sourmash compare --output `. The default output is two PNG files. Usage: ``` -sourmash plot +sourmash plot ``` Options: @@ -243,39 +267,51 @@ Example output: ### `sourmash search` - search for signatures in collections or databases -The `search` subcommand searches a collection of signatures or SBTs for -matches to the query signature. It can search for matches with either +The `search` subcommand searches a collection of signatures +(in any of the [formats supported by sourmash](#storing-and-searching-signatures)) +for matches to the query signature. It can search for matches with either high [Jaccard similarity](https://en.wikipedia.org/wiki/Jaccard_index) or containment; the default is to use Jaccard similarity, unless `--containment` is specified. `-o/--output` will create a CSV file -containing the matches. +containing all of the matches with respective similarity or containment score. -`search` will load all of provided signatures into memory, which can -be slow and somewhat memory intensive for large collections. You can -use `sourmash index` to create a Sequence Bloom Tree (SBT) that can -be quickly searched on disk; this is [the same format in which we provide -GenBank and other databases](databases.md). +`search` makes use of [indexed databases](#loading-many-signatures) to +decrease search time and memory where possible. Usage: ``` -sourmash search query.sig [ list of signatures or SBTs ] +sourmash search query.sig [ ... ] ``` Example output: ``` -49 matches; showing first 20: +% sourmash search tests/test-data/47.fa.sig gtdb-rs207.genomic-reps.dna.k31.zip + +... +-- +loaded 65703 total signatures from 1 locations. +after selecting signatures compatible with search, 65703 remain. + +2 matches above threshold 0.080: similarity match ---------- ----- - 75.4% NZ_JMGW01000001.1 Escherichia coli 1-176-05_S4_C2 e117605... - 72.2% NZ_GG774190.1 Escherichia coli MS 196-1 Scfld2538, whole ... - 71.4% NZ_JMGU01000001.1 Escherichia coli 2-011-08_S3_C2 e201108... - 70.1% NZ_JHRU01000001.1 Escherichia coli strain 100854 100854_1... - 69.0% NZ_JH659569.1 Escherichia coli M919 supercont2.1, whole g... -... + 32.3% GCF_900456975.1 Shewanella baltica strain=NCTC10735, 5088... + 14.0% GCF_002838165.1 Shewanella sp. Pdp11 strain=Pdp11, ASM283... ``` -Note, as of sourmash 4.2.0, `search` supports `--picklist`, to +`search` takes a number of command line options - +* `--containment` - find matches using the containment index rather than Jaccard similarity; +* `--max-containment` - find matches using the max containment index rather than Jaccard similarity; +* `-t/--threshold` - lower threshold for matching; defaults to 0.08; +* `--best-only` - find and report only the best match; +* `-n/--num-results` - number of matches to report to stdout; defaults to 3; 0 to report all; + +Match information can be saved to a CSV file with `-o/--output`; with +`-o`, all matches above the threshold will be saved, not just those +printed to stdout (which are limited to `-n/--num-results`). + +As of sourmash 4.2.0, `search` supports `--picklist`, to [select a subset of signatures to search, based on a CSV file](#using-picklists-to-subset-large-collections-of-signatures). This can be used to search only a small subset of a large collection, or to exclude a few signatures from a collection, without modifying the @@ -295,14 +331,14 @@ will be abundance weighted (unless `--ignore-abundances` is specified). `-o/--output` will create a CSV file containing the matches. -`gather`, like `search`, will load all of provided signatures into -memory. You can use `sourmash index` to create a Sequence Bloom Tree -(SBT) that can be quickly searched on disk; this is -[the same format in which we provide GenBank and other databases](databases.md). +`gather`, like `search`, works with any of the +[signature collection formats supported by sourmash](#storing-and-searching-signatures) +and will make use of [indexed databases](#loading-many-signatures) to +decrease search time and memory where possible. Usage: ``` -sourmash gather query.sig [ list of signatures or SBTs ] +sourmash gather query.sig [ ... ] ``` Example output: @@ -375,7 +411,7 @@ of signatures to include when running `index`. Usage: ``` -sourmash index database [ list of input signatures/directories/databases ] +sourmash index [ ... ] ``` This will create a `database.sbt.zip` file containing the SBT of the @@ -438,7 +474,7 @@ searched in combination with `search`, `gather`, `compare`, etc. A motivating use case for `sourmash prefetch` is to run it on multiple large databases with a metagenome query using `--threshold-bp=0`, -`--save-matching-hashes matching_hashes.sig`, and `--save-matches +`--save-matching-hashes matching-hashes.sig`, and `--save-matches db-matches.sig`, and then run `sourmash gather matching-hashes.sig db-matches.sig`. @@ -457,7 +493,8 @@ The sourmash `tax` or `taxonomy` commands integrate taxonomic `gather` command (we cannot combine separate `gather` runs for the same query). For supported databases (e.g. GTDB, NCBI), we provide taxonomy csv files, but they can also be generated for user-generated - databases. For more information, see [databases](databases.md). + databases. As of v4.8, some sourmash taxonomy commands can also use `LIN` + lineage information. For more information, see [databases](databases.md). `tax` commands rely upon the fact that `gather` provides both the total fraction of the query matched to each database matched, as well as a @@ -470,10 +507,9 @@ The sourmash `tax` or `taxonomy` commands integrate taxonomic taxonomic rank. For example, if the gather results for a metagenome include results for 30 different strains of a given species, we can sum the fraction uniquely matched to each strain to obtain the fraction - uniquely matched to this species. Note that this summarization can - also take into account abundance weighting; see - [classifying signatures](classifying-signatures.md) for more - information. + uniquely matched to this species. Alternatively, taxonomic summarization + can take into account abundance weighting; see + [classifying signatures](classifying-signatures.md) for more information. As with all reference-based analysis, results can be affected by the completeness of the reference database. However, summarizing taxonomic @@ -483,7 +519,6 @@ As with all reference-based analysis, results can be affected by the For more details on how `gather` works and can be used to classify signatures, see [classifying-signatures](classifying-signatures.md). - ### `sourmash tax metagenome` - summarize metagenome content from `gather` results `sourmash tax metagenome` summarizes gather results for each query metagenome by @@ -498,8 +533,13 @@ sourmash tax metagenome --taxonomy gtdb-rs202.taxonomy.v2.csv ``` -There are three possible output formats, `csv_summary`, `lineage_summary`, and - `krona`. +The possible output formats are: +- `human` +- `csv_summary` +- `lineage_summary` +- `krona` +- `kreport` +- `lingroup_report` #### `csv_summary` output format @@ -572,7 +612,7 @@ sourmash tax metagenome --gather-csv HSMA33MX_gather_x_gtdbrs202_k31.csv \ --gather-csv PSM6XBW3_gather_x_gtdbrs202_k31.csv \ --taxonomy gtdb-rs202.taxonomy.v2.csv \ - --output-format krona --rank species + --output-format lineage_summary --rank species ``` example `lineage_summary`: @@ -588,18 +628,212 @@ To produce multiple output types from the same command, add the types into the `--output-format` argument, e.g. `--output-format summary krona lineage_summary` +#### `kreport` output format + +The `kreport` output reports kraken-style `kreport` output, which may be useful for +comparison with other taxonomic profiling methods. While this format typically +records the percent of number of reads assigned to taxa, we create ~comparable +output by reporting the percent of k-mers matched to each taxon and the estimated +number of base pairs that these k-mers represent. To best represent the percent of all +reads, we use k-mer abundance information in this output. To generate this properly, query +FracMinHash sketches should be generated with abundance information (`-p abund`) to allow +abundance-weighted `gather` results. + +Note: `sourmash gather` makes all assignments to genomes, and then `sourmash tax` +integrates taxonomy information and uses LCA-style summarization to build assignments. +For species-level specificity, our current recommendation is to use use our default +k-mer size of 31. + +standard `kreport` columns (read-based tools): +- `Percent Reads Contained in Taxon`: The cumulative percentage of reads for this taxon and all descendants. +- `Number of Reads Contained in Taxon`: The cumulative number of reads for this taxon and all descendants. +- `Number of Reads Assigned to Taxon`: The number of reads assigned directly to this taxon (not a cumulative count of all descendants). +- `Rank Code`: (U)nclassified, (R)oot, (D)omain, (K)ingdom, (P)hylum, (C)lass, (O)rder, (F)amily, (G)enus, or (S)pecies. +- `NCBI Taxon ID`: Numerical ID from the NCBI taxonomy database. +- `Scientific Name`: The scientific name of the taxon. + +Example reads-based `kreport` with all columns: + +``` + 88.41 2138742 193618 K 2 Bacteria + 0.16 3852 818 P 201174 Actinobacteria + 0.13 3034 0 C 1760 Actinomycetia + 0.13 3034 45 O 85009 Propionibacteriales + 0.12 2989 1847 F 31957 Propionibacteriaceae + 0.05 1142 352 G 1912216 Cutibacterium + 0.03 790 790 S 1747 Cutibacterium acnes +``` + +sourmash `kreport` columns: +- `Percent [k-mers] contained in taxon`: The cumulative percentage of k-mers for this taxon and all descendants. +- `Estimated base pairs contained in taxon`: The cumulative estimated base pairs for this taxon and all descendants. +- `Estimated base pairs "assigned" (species-level)`: The estimated base pairs assigned at species-level (cumulative count of base pairs assigned to individual genomes in this species). +- `Rank Code`: (U)nclassified, (R)oot, (D)omain, (K)ingdom, (P)hylum, (C)lass, (O)rder, (F)amily, (G)enus, or (S)pecies. +- `NCBI Taxon ID`: Reported (v4.7+) if using NCBI taxonomy. Otherwise blank. +- `Scientific Name`: The scientific name of the taxon. + +notes: +- `gather` assigns k-mers to specific genomes. To mimic the output of other + tools, we report all results as "assigned" to species-level, which summarizes + the k-mers matched to each genome within a given species. Hence, column 3 will + show all estimated base pairs at this level, and 0 for all other ranks. + Column 2 contains the summarized info at the higher ranks. +- Since `gather` results are non-overlapping and all assignments are done at the + genome level, the percent match (first column) will sum to 100% at each rank + (aside from rounding issues) when including the unclassified (U) percentage. + Higher-rank assignments are generated using LCA-style summarization of genome + matches. +- Rows are ordered by rank and then ~percent containment. + + +example sourmash `{output-name}.kreport.txt`: + +``` +92.73 64060000 D Bacteria +0.44 11299000 D Eukaryota +6.82 284315000 U unclassified +60.23 30398000 P Proteobacteria +21.86 22526000 P Firmicutes +10.41 5250000 P Bacteroidetes +. +. +. +3.94 6710000 S Escherichia coli +4.56 6150000 S Pseudomonas aeruginosa +0.71 5801000 S Clostridium beijerinckii +2.55 5474000 S Bacillus cereus +21.95 4987000 S Escherichia sp. XD7 +28.57 4124000 S Cereibacter sphaeroides +0.25 4014000 S Acinetobacter baumannii +7.23 3934000 S Staphylococcus haemolyticus +0.09 3187000 S Phocaeicola vulgatus +0.61 2820000 S Streptococcus agalactiae +0.20 2499000 S Cutibacterium acnes +0.03 2339000 S Deinococcus radiodurans +10.31 2063000 S Porphyromonas gingivalis +9.24 2011000 S Streptococcus mutans +``` + + +#### `lingroup` output format + +When using LIN taxonomic information, you can optionally also provide a `lingroup` file with two required columns: `name` and `lin`. If provided, we will produce a file, `{base}.lingroups.tsv`, where `{base}` is the name provided via the `-o`,` --output-base` option. This output will select information from the full summary that match the LIN prefixes provided as groups. + +This output format consists of four columns: +- `name`, `lin` columns are taken directly from the `--lingroup` file +- `percent_containment`, the total percent of the dataset contained in this lingroup and all descendants +- `num_bp_contained`, the estimated number of base pairs contained in this lingroup and all descendants. + +Similar to `kreport` above, we use the wording "contained" rather than "assigned," because `sourmash` assigns matches at the genome level, and the `tax` functions summarize this information. + +example output: +``` +name lin percent_containment num_bp_contained +lg1 0;0;0 5.82 714000 +lg2 1;0;0 5.05 620000 +lg3 2;0;0 1.56 192000 +lg3 1;0;1 0.65 80000 +lg4 1;0;1;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0 0.65 80000 +``` + +Related lingroup subpaths will be grouped in output, but exact ordering may change between runs. + +#### `bioboxes` output format + +When using standard taxonomic ranks (not lins), you can choose to output a 'bioboxes' profile, `{base}.bioboxes.profile`, where `{base}` is the name provided via the `-o/--output-base` option. This output is organized according to the [bioboxes profile specifications](https://github.com/bioboxes/rfc/tree/master/data-format) so that this file can be used for CAMI challenges. + +This output format starts with some header information: +``` +#CAMI Submission for Taxonomic Profiling +@Version:0.9.3 +@SampleID:SAMPLEID +@Ranks:superkingdom|phylum|class|order|family|genus|species|strain +@__program__:sourmash +@@TAXID RANK TAXPATH TAXPATHSN PERCENTAGE +``` +and then provides taxonomic profiling information in the tab-separated columns described by the last header line: + +- `TAXID` - specifies a unique alphanumeric ID for a node in a reference tree such as the NCBI taxonomy +- `RANK` - superkingdom --> strain +- `TAXPATH` - the path from the root of the reference taxonomy to the respective taxon +- `TAXPATHSN` - scientific names of taxpath +- `PERCENTAGE` (0-100) - field specifies what percentage of the sample was assigned to the respective TAXID + +example output (using small test data): +``` +# Taxonomic Profiling Output +@SampleID:test1 +@Version:0.10.0 +@Ranks:superkingdom|phylum|class|order|family|genus|species +@__program__:sourmash +@@TAXID RANK TAXPATH TAXPATHSN PERCENTAGE +2 superkingdom 2 Bacteria 13.08 +976 phylum 2|976 Bacteria|Bacteroidota 7.27 +1224 phylum 2|1224 Bacteria|Pseudomonadota 5.82 +200643 class 2|976|200643 Bacteria|Bacteroidota|Bacteroidia 7.27 +1236 class 2|1224|1236 Bacteria|Pseudomonadota|Gammaproteobacteria 5.82 +171549 order 2|976|200643|171549 Bacteria|Bacteroidota|Bacteroidia|Bacteroidales 7.27 +91347 order 2|1224|1236|91347 Bacteria|Pseudomonadota|Gammaproteobacteria|Enterobacterales 5.82 +171552 family 2|976|200643|171549|171552 Bacteria|Bacteroidota|Bacteroidia|Bacteroidales|Prevotellaceae 5.70 +543 family 2|1224|1236|91347|543 Bacteria|Pseudomonadota|Gammaproteobacteria|Enterobacterales|Enterobacteriaceae 5.82 +815 family 2|976|200643|171549|815 Bacteria|Bacteroidota|Bacteroidia|Bacteroidales|Bacteroidaceae 1.56 +838 genus 2|976|200643|171549|171552|838 Bacteria|Bacteroidota|Bacteroidia|Bacteroidales|Prevotellaceae|Prevotella 5.70 +561 genus 2|1224|1236|91347|543|561 Bacteria|Pseudomonadota|Gammaproteobacteria|Enterobacterales|Enterobacteriaceae|Escherichia 5.82 +909656 genus 2|976|200643|171549|815|909656 Bacteria|Bacteroidota|Bacteroidia|Bacteroidales|Bacteroidaceae|Phocaeicola 1.56 +165179 species 2|976|200643|171549|171552|838|165179 Bacteria|Bacteroidota|Bacteroidia|Bacteroidales|Prevotellaceae|Prevotella|Prevotella copri 5.70 +562 species 2|1224|1236|91347|543|561|562 Bacteria|Pseudomonadota|Gammaproteobacteria|Enterobacterales|Enterobacteriaceae|Escherichia|Escherichia coli 5.82 +821 species 2|976|200643|171549|815|909656|821 Bacteria|Bacteroidota|Bacteroidia|Bacteroidales|Bacteroidaceae|Phocaeicola|Phocaeicola vulgatus 1.56 +``` + + +#### `lingroup` output format + +When using LIN taxonomic information, you can optionally also provide a `lingroup` file with two required columns: `name` and `lin`. If provided, we will produce a file, `{base}.lingroups.tsv`, where `{base}` is the name provided via the `-o`,` --output-base` option. This output will select information from the full summary that match the LIN prefixes provided as groups. + +This output format consists of four columns: +- `name`, `lin` columns are taken directly from the `--lingroup` file +- `percent_containment`, the total percent of the dataset contained in this lingroup and all descendants +- `num_bp_contained`, the estimated number of base pairs contained in this lingroup and all descendants. + +Similar to `kreport` above, we use the wording "contained" rather than "assigned," because `sourmash` assigns matches at the genome level, and the `tax` functions summarize this information. + +example output: +``` +name lin percent_containment num_bp_contained +lg1 0;0;0 5.82 714000 +lg2 1;0;0 5.05 620000 +lg3 2;0;0 1.56 192000 +lg3 1;0;1 0.65 80000 +lg4 1;0;1;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0 0.65 80000 +``` + +Related lingroup subpaths will be grouped in output, but exact ordering may change between runs. + ### `sourmash tax genome` - classify a genome using `gather` results `sourmash tax genome` reports likely classification for each query, - based on `gather` matches. By default, classification requires at least 10% of - the query to be matched. Thus, if 10% of the query was matched to a species, the - species-level classification can be reported. However, if 7% of the query was - matched to one species, and an additional 5% matched to a different species in - the same genus, the genus-level classification will be reported. + based on `gather` matches. By default, classification requires at least 10% + of the query to be matched. Thus, if 10% of the query was matched to a species, + the species-level classification can be reported. However, if 7% of the query + was matched to one species, and an additional 5% matched to a different species + in the same genus, the genus-level classification will be reported. + +`sourmash tax genome` can use an ANI threshold (`--ani-threshold`) instead of a + containment threshold. This works the same way as the containment threshold + (and indeed, is using the same underlying information). Note that for DNA k-mers, + k=21 ANI is most similar to alignment-based ANI values, and ANI values should only + be compared if they were generated using the same ksize. Optionally, `genome` can instead report classifications at a desired `rank`, regardless of match threshold (`--rank` argument, e.g. `--rank species`). +If using `--lins` taxonomy, you can also provide a `--lingroup` file containing two +columns, `name`, and `lin`, which provide a series of lin prefixes of interest. +If provided, genome classification will be restricted to provided lingroups only. +All other options (`--rank`, `--ani-threshold`, etc) should continue to function. +If you specify a `--rank` that does not have an associated lingroup, sourmash will +notify you that you eliminated all classification options. + Note that these thresholds and strategies are under active testing. To illustrate the utility of `genome`, let's consider a signature consisting @@ -700,22 +934,30 @@ To produce multiple output types from the same command, add the types into the ### `sourmash tax annotate` - annotates gather output with taxonomy `sourmash tax annotate` adds a column with taxonomic lineage information - for each database match to gather output. Do not summarize or classify. - Note that this is not required for either `summarize` or `classify`. + for each genome match in the gather output, without LCA summarization + or classification. This format is not required for either `metagenome` + or `genome`, but may be helpful for other downstream analyses. -By default, `annotate` uses the name of each input gather csv to write an updated - version with lineages information. For example, annotating `sample1.gather.csv` - would produce `sample1.gather.with-lineages.csv` +By default, `annotate` uses the name of each input gather csv to write +an updated version with lineages information. For example, annotating +`sample1.gather.csv` would produce `sample1.gather.with-lineages.csv`. +This will produce an annotated gather CSV, `Sb47+63_gather_x_gtdbrs202_k31.with-lineages.csv`: ``` sourmash tax annotate --gather-csv Sb47+63_gather_x_gtdbrs202_k31.csv \ --taxonomy gtdb-rs202.taxonomy.v2.csv ``` -> This will produce an annotated gather CSV, `Sb47+63_gather_x_gtdbrs202_k31.with-lineages.csv` + +The `with-lineages` output file format can be summarized with +`sourmash tax summarize` and can also be used as an input taxonomy +spreadsheet for any of the tax subcommands (new as of v4.6.0). ### `sourmash tax prepare` - prepare and/or combine taxonomy files +`sourmash tax prepare` prepares taxonomy files for other `sourmash tax` +commands. + All `sourmash tax` commands must be given one or more taxonomy files as parameters to the `--taxonomy` argument. These files can be either CSV files or (as of sourmash 4.2.1) sqlite3 databases. sqlite3 databases @@ -740,6 +982,96 @@ can be set to CSV like so: sourmash tax prepare --taxonomy file1.csv file2.db -o tax.csv -F csv ``` +**Note:** As of sourmash v4.6.0, the output of `sourmash tax annotate` can + be used as a taxonomy input spreadsheet as well. + +### `sourmash tax grep` - subset taxonomies and create picklists based on taxonomy string matches + +(`sourmash tax grep` is a new command as of sourmash v4.5.0.) + +`sourmash tax grep` searches taxonomies for matching strings, +optionally restricting the string search to a specific taxonomic rank. +It creates new files containing matching taxonomic entries; these new +files can serve as taxonomies and can also be used as +[picklists to restrict database matches](#using-picklists-to-subset-large-collections-of-signatures). + +Usage: +``` +sourmash tax grep -t [ ...] +``` +where `pattern` is a regular expression; see Python's +[Regular Expression HOWTO for details on supported regexp features](https://docs.python.org/3/howto/regex.html#regex-howto). + +For example, +``` +sourmash tax grep Shew -t gtdb-rs207.taxonomy.sqldb -o shew-picklist.csv +``` +will search for a string match to `Shew` within the entire GTDB RS207 +taxonomy, and will output a subset taxonomy in `shew-picklist.csv`. +This picklist can be used with the GTDB +RS207 databases like so: +``` +sourmash search query.sig gtdb-rs207.genomic.k31.zip \ + --picklist shew-picklist.csv:ident:ident +``` + +`tax grep` can also restrict string matching to a specific taxonomic rank +with `-r/--rank`; for example, +``` +sourmash tax grep Shew -t gtdb-rs207.taxonomy.sqldb \ + -o shew-picklist.csv -r genus +``` +will restrict matches to the rank of genus. Available ranks are +superkingdom, phylum, class, order, family, genus, and species. + +`tax grep` also takes several standard grep arguments, including `-i` +to ignore case and `-v` to output only taxonomic lineages that do +_not_ match the pattern. + +Currently only CSV output (optionally gzipped) is supported; use `sourmash tax prepare` to +convert CSV output from `tax grep` into a sqlite3 taxonomy database. + +### `sourmash tax summarize` - print summary information for lineage spreadsheets or taxonomy databases + +(`sourmash tax summarize` is a new command as of sourmash v4.6.0.) + +`sourmash tax summarize` loads in one or more lineage spreadsheets, +counts the distinct taxonomic lineages, and outputs a summary. It +optionally will output a CSV file with a detailed count of how many +identifiers belong to each taxonomic lineage. + +For example, +``` +sourmash tax summarize gtdb-rs202.taxonomy.v2.db -o ranks.csv +``` +outputs +``` +number of distinct taxonomic lineages: 258406 +rank superkingdom: 2 distinct taxonomic lineages +rank phylum: 169 distinct taxonomic lineages +rank class: 419 distinct taxonomic lineages +rank order: 1312 distinct taxonomic lineages +rank family: 3264 distinct taxonomic lineages +rank genus: 12888 distinct taxonomic lineages +rank species: 47894 distinct taxonomic lineages +``` + +and creates a file `ranks.csv` with the number of distinct identifier +counts for each lineage at each rank: +``` +rank,lineage_count,lineage +superkingdom,254090,d__Bacteria +phylum,120757,d__Bacteria;p__Proteobacteria +class,104665,d__Bacteria;p__Proteobacteria;c__Gammaproteobacteria +order,64157,d__Bacteria;p__Proteobacteria;c__Gammaproteobacteria;o__Enterobacterales +family,55347,d__Bacteria;p__Proteobacteria;c__Gammaproteobacteria;o__Enterobacterales;f__Enterobacteriaceae +... +``` +That is, there are 254,090 identifiers in GTDB rs202 under `d__Bacteria`, +and 120,757 within the `p__Proteobacteria`. + +`tax summarize` can also be used to summarize the output of `tax annotate`. + ## `sourmash lca` subcommands for in-memory taxonomy integration These commands use LCA databases (created with `lca index`, below, or @@ -1147,8 +1479,12 @@ then the merged signature will have the sum of all abundances across the individual signatures. The `--flatten` flag will override this behavior and allow merging of mixtures by removing all abundances. -Note: `merge` only creates one output file, with one signature in it, -in the JSON `.sig` format. +`sig merge` can only merge compatible sketches - if there are multiple +k-mer sizes or molecule types present in any of the signature files, +you will need to choose one k-mer size with `-k/--ksize`, and/or one +moltype with `--dna/--protein/--hp/--dayhoff`. + +Note: `merge` only creates one output file, with one signature in it. ### `sourmash signature rename` - rename a signature @@ -1179,8 +1515,12 @@ will subtract all of the hashes in `file2.sig` and `file3.sig` from To use `subtract` on signatures calculated with `-p abund`, you must specify `--flatten`. -Note: `subtract` only creates one output file, with one signature in it, -in the JSON `.sig` format. +`sig subtract` can only work with compatible sketches - if there are multiple +k-mer sizes or molecule types present in any of the signature files, +you will need to choose one k-mer size with `-k/--ksize`, and/or one +moltype with `--dna/--protein/--hp/--dayhoff`. + +Note: `subtract` only creates one output file, with one signature in it. ### `sourmash signature intersect` - intersect two (or more) signatures @@ -1200,6 +1540,11 @@ in any signatures will be ignored and the output signature will have borrow abundances from the specified signature (which will also be added to the intersection). +`sig intersect` can only work with compatible sketches - if there are multiple +k-mer sizes or molecule types present in any of the signature files, +you will need to choose one k-mer size with `-k/--ksize`, and/or one +moltype with `--dna/--protein/--hp/--dayhoff`. + ### `sourmash signature inflate` - transfer abundances from one signature to others Use abundances from one signature to provide abundances on other signatures. @@ -1214,6 +1559,11 @@ the abundances on matching hashes in `file2.sig` and `file3.sig`. Any hashes that are not present in `file1.sig` will be removed from `file2.sig` and `file3.sig` as they will now have zero abundance. +`sig inflate` can only work with compatible sketches - if there are multiple +k-mer sizes or molecule types present in any of the signature files, +you will need to choose one k-mer size with `-k/--ksize`, and/or one +moltype with `--dna/--protein/--hp/--dayhoff`. + ### `sourmash signature downsample` - decrease the size of a signature Downsample one or more signatures. @@ -1292,7 +1642,7 @@ or equal to 2, and less than or equal to 5. For example, ``` -sourmash signature -m 2 *.sig +sourmash signature filter -m 2 *.sig ``` will output new signatures containing only hashes that occur two or @@ -1311,8 +1661,7 @@ sourmash signature import filename.msh.json -o imported.sig ``` will import the contents of `filename.msh.json` into `imported.sig`. -Note: `import` only creates one output file, with one signature in it, -in the JSON `.sig` format. +Note: `import` only creates one output file, with one signature in it. ### `sourmash signature export` - export signatures to mash. @@ -1344,6 +1693,10 @@ sourmash signature overlap file1.sig file2.sig ``` will display the detailed comparison of `file1.sig` and `file2.sig`. +`sig overlap` can only work with compatible sketches - if there are multiple +k-mer sizes or molecule types present in any of the signature files, +you will need to choose one k-mer size with `-k/--ksize`, and/or one +moltype with `--dna/--protein/--hp/--dayhoff`. ### `sourmash signature kmers` - extract k-mers and/or sequences that match to signatures @@ -1746,3 +2099,19 @@ situations where you have a **very large** collection of signatures in the collection (as you would have to, with a zipfile). This can be useful if you want to refer to different subsets of the collection without making multiple copies in a zip file. + +### Using sourmash plugins + +As of sourmash v4.7.0, sourmash has an experimental plugins interface! +The plugin interface supports extending sourmash to load and save +signatures in new ways, and also supports the addition of sourmash +subcommands via `sourmash scripts`. + +In order to use a plugin with sourmash, you will need to use `pip` +or `conda` to install the plugin the same environment that sourmash +is installed in. + +In the future, we will include a list of available sourmash plugins in +the documentation, and also provide a way to list available plugins. + +You can list all installed plugins with `sourmash info -v`. diff --git a/doc/conf.py b/doc/conf.py index bb3f9e4f19..23331efe08 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -37,7 +37,7 @@ 'sphinx.ext.doctest', 'sphinx.ext.coverage', 'sphinx.ext.viewcode', - 'sphinx.ext.napoleon', +# 'sphinx.ext.napoleon', 'nbsphinx', 'IPython.sphinxext.ipython_console_highlighting', 'myst_parser' @@ -65,8 +65,8 @@ # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. -from pkg_resources import get_distribution -release = get_distribution('sourmash').version +from importlib.metadata import version +release = version('sourmash') version = '.'.join(release.split('.')[:2]) # The language for content autogenerated by Sphinx. Refer to documentation @@ -74,7 +74,7 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "en" # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: diff --git a/doc/databases.md b/doc/databases.md index 61803438d0..229029ac22 100644 --- a/doc/databases.md +++ b/doc/databases.md @@ -15,6 +15,13 @@ You can read more about the different database and index types [here](https://so Note that the SBT and LCA databases can be used with sourmash v3.5 and later, while Zipfile collections can only be used with sourmash v4.1 and up. +## Taxonomic Information (for non-LCA databases) + +For each prepared database, we have also made taxonomic information available linking each genome with its assigned lineage (`GTDB` or `NCBI` as appropriate). +For private databases, users can create their own `taxonomy` files: the critical columns are `ident`, containing the genome accession (e.g. `GCA_1234567.1`) and +a column for each taxonomic rank, `superkingdom` to `species`. If a `strain` column is provided, it will also be used. +As of v4.8, we can also use LIN taxonomic information in tax commands that accept the `--lins` flag. If used, `sourmash tax` commands will require a `lin` column in the taxonomy file which should contain `;`-separated LINs, preferably with a standard number of positions (e.g. all 20 positions in length or all 10 positions in length). Some taxonomy commands also accept a `lingroups` file, which is a two-column file (`name`, `lin`) describing the name and LIN prefix of LINgroups to be used for taxonomic summarization. + ## Downloading and using the databases All databases below can be downloaded via the command line with `curl -JLO `, where `` is the URL below. This will download an appropriately named file; you can name it yourself by specify `'-o ` to specify the local filename. @@ -23,37 +30,46 @@ The databases do not need to be unpacked or prepared in any way after download. You can verify that they've been successfully downloaded (and view database properties such as `ksize` and `scaled`) with `sourmash sig summarize `. -## GTDB R07-RS207 - DNA databases +## GTDB R08-RS214 - DNA databases -[GTDB R07-RS207](https://forum.gtdb.ecogenomic.org/t/announcing-gtdb-r07-rs207/264) consists of 317,542 genomes organized into 65,703 species clusters. +[GTDB R08-RS214](https://forum.gtdb.ecogenomic.org/t/announcing-gtdb-r08-rs214/456) consists of 402,709 genomes organized into 85,205 species clusters. -The lineage spreadsheet (for `sourmash tax` commands) is available [at the species level](https://osf.io/v3zmg/download) and [at the strain level](https://osf.io/r87td/download). +The lineage spreadsheet (for `sourmash tax` commands) is available [at the species level](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs214/gtdb-rs214.lineages.csv.gz). -### GTDB R07-RS207 genomic representatives (66k) +### GTDB R08-RS214 genomic representatives (85k) -The GTDB genomic representatives are a low-redundancy subset of Genbank genomes, with 65,703 species-level genomes. +The GTDB genomic representatives are a low-redundancy subset of Genbank genomes, with 85,205 species-level genomes. | K-mer size | Zipfile collection | SBT | LCA | | -------- | -------- | -------- | ---- | -| 21 | [download (1.7 GB)](https://osf.io/f2wzc/download) | [download (3.5 GB)](https://osf.io/zsypg/download) | [download (181 MB)](https://osf.io/pm35d/download) | -| 31 | [download (1.7 GB)](https://osf.io/3a6gn/download) | [download (3.5 GB)](https://osf.io/ernct/download) | [download (181 MB)](https://osf.io/p9ezm/download) | -| 51 | [download (1.7 GB)](https://osf.io/f23qn/download) | [download (3.5 GB)](https://osf.io/yq7dc/download) | [download (181 MB)](https://osf.io/8qhgy/download) | +| 21 | [download (2.2 GB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs214/gtdb-rs214-reps.k21.zip) | [download (4.4 GB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs214/gtdb-rs214-reps.k21.sbt.zip) | [download (189 MB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs214/gtdb-rs214-reps.k21.lca.json.gz) | +| 31 | [download (2.2 GB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs214/gtdb-rs214-reps.k31.zip) | [download (4.4 GB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs214/gtdb-rs214-reps.k31.sbt.zip) | [download (221 MB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs214/gtdb-rs214-reps.k31.lca.json.gz) | +| 51 | [download (2.2 GB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs214/gtdb-rs214-reps.k51.zip) | [download (4.4 GB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs214/gtdb-rs214-reps.k51.sbt.zip) | [download (230 MB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs214/gtdb-rs214-reps.k51.lca.json.gz) | -### GTDB R07-RS207 all genomes (318k) +### GTDB R08-RS214 all genomes (403k) -These are databases for the full GTDB release, each containing 317,542 genomes. +These are databases for the full GTDB release, each containing 402,709 genomes. | K-mer size | Zipfile collection | SBT | LCA | | -------- | -------- | -------- | ---- | -| 21 | [download (9.4 GB)](https://osf.io/9gpck/download) | [download (19 GB)](https://osf.io/wr8pk/download) | [download (351 MB)](https://osf.io/su9za/download) | -| 31 | [download (9.4 GB)](https://osf.io/k2u8s/download) | [download (19 GB)](https://osf.io/748ew/download) | [download (351 MB)](https://osf.io/tf3ah/download) | -| 51 | [download (9.4 GB)](https://osf.io/ubt7p/download) | [download (19 GB)](https://osf.io/78hdr/download) | [download (351 MB)](https://osf.io/vc8ua/download) | +| 21 | [download (12 GB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs214/gtdb-rs214-k21.zip) | [download (23 GB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs214/gtdb-rs214-k21.sbt.zip) | [download (406 MB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs214/gtdb-rs214-k21.lca.json.gz) | +| 31 | [download (12 GB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs214/gtdb-rs214-k31.zip) | [download (23 GB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs214/gtdb-rs214-k31.sbt.zip) | [download (438 MB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs214/gtdb-rs214-k31.lca.json.gz) | +| 51 | [download (12 GB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs214/gtdb-rs214-k51.zip) | [download (23 GB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs214/gtdb-rs214-k51.sbt.zip) | [download (460 MB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs214/gtdb-rs214-k51.lca.json.gz) | ## Genbank genomes from March 2022 -The below zip files contain signatures for all microbial Genbank genomes as of March 2022, based on the assembly_summary files provided [here](https://ftp.ncbi.nlm.nih.gov/genomes/genbank/). +The below zip files contain different subsets of the signatures for +all microbial Genbank genomes. The databases were built in March 2022, +and are based on the assembly_summary files provided +[here](https://ftp.ncbi.nlm.nih.gov/genomes/genbank/). -Since some of the files are extremely large, we only provide them in Zip format. +Since some of the files are extremely large, we only provide them in +Zip format (which is our smallest and most flexible format). + +Note that all of the sourmash search commands support multiple +databases on the command line, so you can search multiple subsets +simply by providing them all on the command line, e.g. `sourmash +search query.sig genbank-2022.03-{viral,protozoa}-k31.zip`. Taxonomic spreadsheets for each domain are provided below as well. @@ -61,63 +77,90 @@ Taxonomic spreadsheets for each domain are provided below as well. 47,952 genomes: -[genbank-2022.03-viral-k21.zip](https://dweb.link/ipfs/bafybeicjyx6qkhdtw6q4cxs6fyl46gqfhd4q5eqje5lkswf2npljnyytzi) +[genbank-2022.03-viral-k21.zip](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/genbank-2022.03/genbank-2022.03-viral-k21.zip) -[genbank-2022.03-viral-k31.zip](https://dweb.link/ipfs/bafybeibqsldwsztjf66rwvwnb6hamjtsfkmdk5bmfqbzwrod6wwwkqz2ya) +[genbank-2022.03-viral-k31.zip](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/genbank-2022.03/genbank-2022.03-viral-k31.zip) -[genbank-2022.03-viral-k51.zip](https://dweb.link/ipfs/bafybeibgifuv4q3mihfubnhjhwm2esjnoseudpnzwahlkp3hvlbmtd4s2q) +[genbank-2022.03-viral-k51.zip](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/genbank-2022.03/genbank-2022.03-viral-k51.zip) -[genbank-2022.03-viral.lineages.csv.gz](https://osf.io/j4tsu/download) +[genbank-2022.03-viral.lineages.csv.gz](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/genbank-2022.03/genbank-2022.03-viral.lineages.csv.gz) ### Genbank archaeal 8,750 genomes: -[genbank-2022.03-archaea-k21.zip](https://dweb.link/ipfs/bafybeiepywe7c6zjzgh3rksqiwpo5zyb7uuefbgvbn5nkgiq77iaavpzl4) +[genbank-2022.03-archaea-k21.zip](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/genbank-2022.03/genbank-2022.03-archaea-k21.zip) -[genbank-2022.03-archaea-k31.zip](https://dweb.link/ipfs/bafybeidn6epju7yrdxrktq5wjko2yiwp6nrx3mq37htiuwecm7lffrbcdi) +[genbank-2022.03-archaea-k31.zip](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/genbank-2022.03/genbank-2022.03-archaea-k31.zip) -[genbank-2022.03-archaea-k51.zip](https://dweb.link/ipfs/bafybeifyrwbx5dnay4mflboc5zai2de3xrvcxtgiu4j7adzj6qrxhb3zva) +[genbank-2022.03-archaea-k51.zip](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/genbank-2022.03/genbank-2022.03-archaea-k51.zip) -[genbank-2022.03-archaea.lineages.csv.gz](https://osf.io/kcbpn/download) +[genbank-2022.03-archaea.lineages.csv.gz](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/genbank-2022.03/genbank-2022.03-archaea.lineages.csv.gz) ### Genbank protozoa 1193 genomes: -[genbank-2022.03-protozoa-k21.zip](https://dweb.link/ipfs/bafybeicfh4xl4wuxd4xy2tf73hfamxlqa3s2higghnsjay4t5wtlmsdo5y) +[genbank-2022.03-protozoa-k21.zip](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/genbank-2022.03/genbank-2022.03-protozoa-k21.zip) -[genbank-2022.03-protozoa-k31.zip](https://dweb.link/ipfs/bafybeicpxjhfrzem7f34eghbbwm3vglz2njxo72vpqcw7foilfomexsghi) +[genbank-2022.03-protozoa-k31.zip](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/genbank-2022.03/genbank-2022.03-protozoa-k31.zip) -[genbank-2022.03-protozoa-k51.zip](https://dweb.link/ipfs/bafybeigfpxkmzyq6sdkob53l6ztiy5ro44dzkad7dxakhuaao6cw4gp4eu) +[genbank-2022.03-protozoa-k51.zip](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/genbank-2022.03/genbank-2022.03-protozoa-k51.zip) -[genbank-2022.03-protozoa.lineages.csv.gz](https://osf.io/2x8u4/download) +[genbank-2022.03-protozoa.lineages.csv.gz](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/genbank-2022.03/genbank-2022.03-protozoa.lineages.csv.gz) ### Genbank fungi 10,286 genomes: -[genbank-2022.03-fungi-k21.zip](https://dweb.link/ipfs/bafybeibrirvek4lxn6hh3wgsmtsd5vz5gtmewpzeg364bix3hojghwmygq) +[genbank-2022.03-fungi-k21.zip](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/genbank-2022.03/genbank-2022.03-fungi-k21.zip) -[genbank-2022.03-fungi-k31.zip](https://dweb.link/ipfs/bafybeidhhwvwujkteno5ugwgjy4brhrv5dff2aumifcuew73qolfktdndq) +[genbank-2022.03-fungi-k31.zip](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/genbank-2022.03/genbank-2022.03-fungi-k31.zip) -[genbank-2022.03-fungi-k51.zip](https://dweb.link/ipfs/bafybeibnrtt45f7wez2xb3fy5rxhatpeevc3rilm2gs65u5h6gc4u72fam) +[genbank-2022.03-fungi-k51.zip](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/genbank-2022.03/genbank-2022.03-fungi-k51.zip) -[genbank-2022.03-fungi.lineages.csv.gz](https://osf.io/s4b85/download) +[genbank-2022.03-fungi.lineages.csv.gz](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/genbank-2022.03/genbank-2022.03-fungi.lineages.csv.gz) ### Genbank bacterial: 1,148,011 genomes: -[genbank-2022.03-bacteria-k21.zip](https://dweb.link/ipfs/bafybeif2hdztfrevkngnfqk3bsoyajxxf67o57u4dezbz647jwcf6gnwoy) +[genbank-2022.03-bacteria-k21.zip](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/genbank-2022.03/genbank-2022.03-bacteria-k21.zip) + +[genbank-2022.03-bacteria-k31.zip](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/genbank-2022.03/genbank-2022.03-bacteria-k31.zip) + +[genbank-2022.03-bacteria-k51.zip](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/genbank-2022.03/genbank-2022.03-bacteria-k51.zip) + +[genbank-2022.03-bacteria.lineages.csv.gz](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/genbank-2022.03/genbank-2022.03-bacteria.lineages.csv.gz) -[genbank-2022.03-bacteria-k31.zip](https://dweb.link/ipfs/bafybeigkcvizvhe3xzxsuzv3ryf3ogvgvcmms2e5nfk7epl5egts22jyue) +## GTDB R07-RS207 - DNA databases + +[GTDB R07-RS207](https://forum.gtdb.ecogenomic.org/t/announcing-gtdb-r07-rs207/264) consists of 317,542 genomes organized into 65,703 species clusters. -[genbank-2022.03-bacteria-k51.zip](https://dweb.link/ipfs/bafybeie3eyyectnh5xqxz44oa3qj5vura3bffqdwfqk6jjuzzadkh7e2sq) +The lineage spreadsheet (for `sourmash tax` commands) is available [at the species level](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs207/gtdb-rs207.species-taxonomy.csv.gz) and [at the genome level](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs207/gtdb-rs207.taxonomy.with-strain.csv.gz). + +### GTDB R07-RS207 genomic representatives (66k) + +The GTDB genomic representatives are a low-redundancy subset of Genbank genomes, with 65,703 species-level genomes. + +| K-mer size | Zipfile collection | SBT | LCA | +| -------- | -------- | -------- | ---- | +| 21 | [download (1.7 GB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs207/gtdb-rs207.genomic-reps.dna.k21.zip) | [download (3.5 GB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs207/gtdb-rs207.genomic-reps.dna.k21.sbt.zip) | [download (181 MB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs207/gtdb-rs207.genomic-reps.dna.k21.lca.json.gz) | +| 31 | [download (1.7 GB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs207/gtdb-rs207.genomic-reps.dna.k31.zip) | [download (3.5 GB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs207/gtdb-rs207.genomic-reps.dna.k31.sbt.zip) | [download (181 MB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs207/gtdb-rs207.genomic-reps.dna.k31.lca.json.gz) | +| 51 | [download (1.7 GB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs207/gtdb-rs207.genomic-reps.dna.k51.zip) | [download (3.5 GB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs207/gtdb-rs207.genomic-reps.dna.k51.sbt.zip) | [download (181 MB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs207/gtdb-rs207.genomic-reps.dna.k51.lca.json.gz) | + +### GTDB R07-RS207 all genomes (318k) + +These are databases for the full GTDB release, each containing 317,542 genomes. + +| K-mer size | Zipfile collection | SBT | LCA | +| -------- | -------- | -------- | ---- | +| 21 | [download (9.4 GB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs207/gtdb-rs207.genomic.k21.zip) | [download (19 GB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs207/gtdb-rs207.genomic.k21.sbt.zip) | [download (351 MB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs207/gtdb-rs207.genomic.k21.lca.json.gz) | +| 31 | [download (9.4 GB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs207/gtdb-rs207.genomic.k31.zip) | [download (19 GB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs207/gtdb-rs207.genomic.k31.sbt.zip) | [download (351 MB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs207/gtdb-rs207.genomic.k31.lca.json.gz) | +| 51 | [download (9.4 GB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs207/gtdb-rs207.genomic.k51.zip) | [download (19 GB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs207/gtdb-rs207.genomic.k51.sbt.zip) | [download (351 MB)](https://farm.cse.ucdavis.edu/~ctbrown/sourmash-db/gtdb-rs207/gtdb-rs207.genomic.k51.lca.json.gz) | -[genbank-2022.03-bacteria.lineages.csv.gz](https://osf.io/4agsp/download) ## GTDB R06-RS202 - DNA databases @@ -133,7 +176,7 @@ The GTDB genomic representatives are a low-redundancy subset of Genbank genomes. | 31 | [download (1.3 GB)](https://osf.io/nqmau/download) | [download (2.6 GB)](https://osf.io/w4bcm/download) | [download (131 MB)](https://osf.io/ypsjq/download) | | 51 | [download (1.3 GB)](https://osf.io/px6qd/download) | [download (2.6 GB)](https://osf.io/rv9zp/download) | [download (137 MB)](https://osf.io/297dp/download) | -### GTDB all genomes (258k) +### GTDB R06-RS202 all genomes (258k) These databases contain the complete GTDB collection of 258,406 genomes. diff --git a/doc/dev_plugins.md b/doc/dev_plugins.md new file mode 100644 index 0000000000..136e882308 --- /dev/null +++ b/doc/dev_plugins.md @@ -0,0 +1,95 @@ +# sourmash plugins via Python entry points + +As of version 4.7.0, sourmash has experimental support for Python +plugins to load and save signatures in different ways (e.g. file +formats, RPC servers, databases, etc.) and to run additional commands +via the command-line. This support is provided via +the "entry points" mechanism supplied by +[`importlib.metadata`](https://docs.python.org/3/library/importlib.metadata.html) +and documented +[here](https://setuptools.pypa.io/en/latest/userguide/entry_point.html). + +```{note} +Note: The plugin API is _not_ finalized or subject to semantic +versioning just yet! Please subscribe to +[sourmash#1353](https://github.com/sourmash-bio/sourmash/issues/1353) +if you want to keep up to date on plugin support. +``` + +You can define entry points in the `pyproject.toml` file +like so: + +``` +[project.entry-points."sourmash.load_from"] +a_reader = "module_name:load_sketches" + +[project.entry-points."sourmash.save_to"] +a_writer = "module_name:SaveSignatures_WriteFile" + +[project.entry-points."sourmash.cli_script"] +new_cli = "module_name:Command_NewCommand" +``` + +Here, `module_name` should be the name of the module to import. + +* `load_sketches` should be a function that takes a location along with +arbitrary keyword arguments and returns an `Index` object +(e.g. `LinearIndex` for a collection of in-memory +signatures). +* `SaveSignatures_WriteFile` should be a class that +subclasses `BaseSave_SignaturesToLocation` and implements its own +mechanisms of saving signatures. See the `sourmash.save_load` module +for saving and loading code already used in sourmash. +* `Command_NewCommand` should be a class that subclasses + `plugins.CommandLinePlugin` and provides an `__init__` and + `main` method. + +Note that if the reader function or writer class has a `priority` +attribute, this will be used to determine the order in which the +plugins are called. Priorities lower than 10 will get called before +any internal load or save function, while priorities greater than 80 +will get called after almost all internal load/save functions; see +`src/sourmash/save_load.py` for details and the current priorities. + +The `name` attribute of the plugin (`a_reader`, `a_writer`, and `new_cli` in +`pyproject.toml`, above) is only used in debugging. + +You can provide zero or more plugins, and you can define just a reader, or +just a writer, or just a CLI plugin. + +## Templates and examples + +If you want to create your own plug-in, you can start with the +[sourmash_plugin_template](https://github.com/sourmash-bio/sourmash_plugin_template) repo. + +Some (early stage) plugins are also available as examples: + +* [sourmash-bio/sourmash_plugin_load_urls](https://github.com/sourmash-bio/sourmash_plugin_load_urls) - load signatures and CSV manifests via [fsspec](https://filesystem-spec.readthedocs.io/). +* [sourmash-bio/sourmash_plugin_avro](https://github.com/sourmash-bio/sourmash_plugin_avro) - use [Apache Avro](https://avro.apache.org/) as a serialization format. + +## Debugging plugins + +`sourmash info -v` will list all installed plugins. + +`sourmash sig cat -o ` is a simple way to +invoke a `save_to` plugin. Use `-d` to turn on debugging output. + +`sourmash sig describe ` is a simple way to invoke +a `load_from` plugin. Use `-d` to turn on debugging output. + +`sourmash scripts` will list available command-line plugins. + +## Semantic versioning and listing sourmash as a dependency + +Plugins should generally list sourmash as a dependency for installation. + +Once plugins are officially supported by sourmash, the plugin API will +be under [semantic versioning constraints](https://semver.org/). That +means that you should constrain plugins to depend on sourmash only up +to the next major version, e.g. sourmash v5. + +Specifically, we suggest placing something like: +``` +dependencies = ['sourmash>=4.8.0,<5'] +``` +in your `pyproject.toml` file. diff --git a/doc/developer.md b/doc/developer.md index 1e4d4dbbeb..d3f83f7924 100644 --- a/doc/developer.md +++ b/doc/developer.md @@ -1,3 +1,7 @@ +```{contents} Contents +:depth: 3 +``` + # Developer information ## Development environment @@ -39,7 +43,7 @@ for setting up Nix in your system (Linux or macOS). Once Nix is installed, run ``` -nix-shell +nix develop ``` to start an environment ready for [running tests and checks](#running-tests-and-checks). @@ -117,11 +121,17 @@ There are three main components in the sourmash repo: - The command-line interface (in `src/sourmash/cli`) - The Rust core library (in `src/core`) -`setup.py` has all the configuration to prepare a Python package containing these three components. +`pyproject.toml` has all the configuration to prepare a Python package containing these +three components. First it compiles the Rust core component into a shared library, -which is wrapped by [CFFI] and exposed to the Python module. +which is wrapped by [cffi] and exposed to the Python module. +These steps are executed by [maturin], +a modern [PEP 517]-compatible build backend for Python projects containing Rust +extensions. -[CFFI]: https://cffi.readthedocs.io/ +[cffi]: https://cffi.readthedocs.io/ +[maturin]: https://www.maturin.rs/ +[PEP 517]: https://peps.python.org/pep-0517/ A short description of the high-level files and dirs in the sourmash repo: ``` @@ -146,14 +156,13 @@ A short description of the high-level files and dirs in the sourmash repo: ├── Makefile | Entry point for most development tasks ├── MANIFEST.in | Describes what files to add to the Python package ├── matplotlibrc | Configuration for matplotlib -├── shell.nix | Nix configuration for creating a dev environment +├── flake.nix | Nix definitions (package, dev env) +├── shell.nix | Nix config for creating a dev env (backward-compatible) ├── paper.bib | References in the JOSS paper ├── paper.md | JOSS paper content ├── pyproject.toml | Python project definitions (build system and tooling) ├── README.md | Info to get started ├── requirements.txt | Python dependencies for development -├── setup.py | Entry point for Python package setup -├── setup.cfg | Python package definitions └── tox.ini | Configuration for test automation ``` @@ -249,7 +258,6 @@ including some tools for evaluating performance changes. ## Versioning -We use [`setuptools_scm`] to generate versions based on git tags. Versions are tagged in a `vMAJOR.MINOR.PATH` format, following the [Semantic Versioning] convention. From their definition: @@ -260,11 +268,14 @@ From their definition: > MINOR version when you add functionality in a backwards compatible manner, and > PATCH version when you make backwards compatible bug fixes. -[`setuptools_scm`]: https://github.com/pypa/setuptools_scm [Semantic Versioning]: https://semver.org/ +The Python version is not automated, +and must be bumped in `pyproject.toml` and `flake.nix`. + For the Rust core library we use `rMAJOR.MINOR.PATCH` (note it starts with `r`, and not `v`). + The Rust version is not automated, and must be bumped in `src/core/Cargo.toml`. @@ -280,7 +291,7 @@ Some installation issues can be solved by simply removing the intermediate build make clean ``` -## Contents +## Additional developer-focused documents ```{toctree} :maxdepth: 2 @@ -289,4 +300,6 @@ release requirements storage release-notes/releases +dev_plugins ``` + diff --git a/doc/environment.yml b/doc/environment.yml deleted file mode 100644 index f3840b2dce..0000000000 --- a/doc/environment.yml +++ /dev/null @@ -1,6 +0,0 @@ -channels: - - conda-forge - - defaults -dependencies: - - rust - - python =3.8 diff --git a/doc/index.md b/doc/index.md index afeaf29084..4e7a9927e3 100644 --- a/doc/index.md +++ b/doc/index.md @@ -79,7 +79,7 @@ be stored, searched, explored, and taxonomically annotated. You can take a look at sourmash analyses on real data [in a saved Jupyter notebook](https://github.com/sourmash-bio/sourmash/blob/latest/doc/sourmash-examples.ipynb), and experiment with it yourself -[interactively in a Jupyter Notebook](https://mybinder.org/v2/gh/sourmash-bio/sourmash/latest?filepath=doc%2Fsourmash-examples.ipynb) +[interactively in a Jupyter Notebook](https://mybinder.org/v2/gh/sourmash-bio/sourmash/latest?labpath=doc%2Fsourmash-examples.ipynb) at [mybinder.org](http://mybinder.org). ## Installing sourmash diff --git a/doc/kmers-and-minhash.ipynb b/doc/kmers-and-minhash.ipynb index 7b1c430d90..f4c5150251 100644 --- a/doc/kmers-and-minhash.ipynb +++ b/doc/kmers-and-minhash.ipynb @@ -18,7 +18,7 @@ "### Running this notebook.\n", "\n", "You can run this notebook interactively via mybinder; click on this button:\n", - "[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/dib-lab/sourmash/latest?filepath=doc%2Fkmers-and-minhash.ipynb)\n", + "[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/dib-lab/sourmash/latest?labpath=doc%2Fkmers-and-minhash.ipynb)\n", "\n", "A rendered version of this notebook is available at [sourmash.readthedocs.io](https://sourmash.readthedocs.io) under \"Tutorials and notebooks\".\n", "\n", @@ -197,7 +197,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 9, @@ -206,7 +206,7 @@ }, { "data": { - "image/png": "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\n", + "image/png": "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\n", "text/plain": [ "

" ] @@ -230,7 +230,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 10, @@ -239,7 +239,7 @@ }, { "data": { - "image/png": "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\n", + "image/png": "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\n", "text/plain": [ "
" ] @@ -505,7 +505,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 22, @@ -514,7 +514,7 @@ }, { "data": { - "image/png": "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\nRK5CYII=\n", + "image/png": "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\n", "text/plain": [ "
" ] @@ -965,7 +965,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 40, @@ -974,7 +974,7 @@ }, { "data": { - "image/png": "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\nRK5CYII=\n", + "image/png": "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\n", "text/plain": [ "
" ] @@ -995,7 +995,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 41, @@ -1004,7 +1004,7 @@ }, { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAV0AAACoCAYAAABDoD2pAAAABHNCSVQICAgIfAhkiAAAAAlwSFlz\nAAALEgAACxIB0t1+/AAAADl0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uIDMuMC4wLCBo\ndHRwOi8vbWF0cGxvdGxpYi5vcmcvqOYd8AAAHjFJREFUeJztnXuU3FWV7z+7+v1I0kk67zcEEvIg\nISRiIEhEVJQRGRn0XgbEx+gsxDvqdcDRgdtTZsQZ7wj3ypK17r1roc4IM6JwWcYgAheQVxJIJEDe\nD0g6SSfppPPq7upXVe37x6mGQDpJd7p+5/we57PWb3WlulJnV/9OfX/7t/c++4iq4vF4PB47pFwb\n4PF4PEnCi67H4/FYxIuux+PxWMSLrsfj8VjEi67H4/FYxIuux+PxWMSLrsfj8VjEi67H4/FYxIuu\nx+PxWMSLrsfj8VjEi67H4/FYxIuux+PxWMSLrsfj8VjEi67H4/FYxIuux+PxWMSLrsfj8VjEi67H\n4/FYxIuux+PxWMSLrsfj8VjEi67H4/FYxIuux+PxWMSLrsfj8VjEi67H4/FYpNS1AVFC0pICagpH\nbeFnCSCFAyB7wtENHAeOaYP2WDe4SIhQAQzBzJfeIwVo4QDzWduANlXyLuz0DBARAYYDIzDzufqE\nowao4N35De+e724g08dxBGhBtdveh4geoqpnflXCKIjraGAcMBIzIWuBKt6dgAMlAxwtHMcKR7M2\naOegDS4CIpQCdcDQPn5WDuCtFOjACHDrCT+bgRZV/IRzhchQYCxQD4zCzO0gHK9jwEHgEOa8N6Pq\nL8QFvOjyjsjWA+MLx1js3QUcBBqB3dqgzZbGBECEOmBS4RiH8WqCpAvYBzQBTaocDni8ZGM82THA\nlMJR58iSbswc3wXsTronnFjRLQjtZGAGRmjL3FoEGA9xD++KcFEnZ8GbHY/53JMwIQOXdGBEeCfw\ntio5t+bEBJGxmHk9hYHdpdggjznn24EdqGYd22OdxImupGUIMBMzKasdm3M6csA2YL026KA8QhGG\nA3OA8whvHL8T2ApsVuWoa2Mih0gp5vzOwoQNokAX5pxvRPWYa2NskQjRLXi1U4ALgAmcfVzWFXuB\nN7VBGwfyn0SYDMzFfOYo0QRswni/PhZ4OkRqgQuB84Fyx9YMhj3AenRgczyKxFp0C2I7E1hAuL3a\n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\nPB6PRf4/9BK4ugTvf7IAAAAASUVORK5CYII=\n", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAV0AAACoCAYAAABDoD2pAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjUuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8qNh9FAAAACXBIWXMAAAsTAAALEwEAmpwYAAAeMklEQVR4nO2de5TdVZXnP/vW+5GkklTeTyBAyIOEkIiBKIioKC3S0ugMDeJjdBbitDo2ODow1de02O20MCNL1pqZtVC7hW5RGJYYRGAAeSWBRF55PyCpJJWkkkoqqapbr3vvnj/OLQikUo/U/Z3ze5zPWr9VN7du7tm3fud+f/u39z77iKri8Xg8HjukXBvg8Xg8ScKLrsfj8VjEi67H4/FYxIuux+PxWMSLrsfj8VjEi67H4/FYxIuux+PxWMSLrsfj8VjEi67H4/FYxIuux+PxWMSLrsfj8VjEi67H4/FYxIuux+PxWMSLrsfj8VjEi67H4/FYxIuux+PxWMSLrsfj8VjEi67H4/FYxIuux+PxWMSLrsfj8VjEi67H4/FYxIuux+PxWMSLrsfj8Vik1LUBUULSkgJqCkdt4WcJIIUDIHvC0QMcB45pg/ZaN7hIiFABjMLMl74jBWjhAPNZ24F2VfIu7PQMExEBxgLjMPO5+oSjBqjg3fkN757vHiDTz3EUaEG1x96HiB6iqoO/KmEUxHUiMAUYj5mQtUAV707A4ZIBWgvHscLRrA3aNUJzi4IIpUAdMLqfn5XDeCsFOjEC3HbCz2agRRU/4VwhMhqYDNQDEzBzOwjH6xhwCDiMOe/NqPoLcQEvurwjsvXA1MIxGXt3AYeARmCPNmizpTEBEKEOmFE4pmC8miDpBvYDTUCTKkcCHi/ZGE92EjCrcNQ5sqQHM8d3A3uS7gknVnQLQjsTOBcjtGVuLQKMh7iXd0W4qJOz4M1OxXzuGZiQgUs6MSK8C3hblZxbc2KCyGTMvJ7F8O5SbJDHnPMdwE5Us47tsU7iRFfSMgqYi5mU1Y7NGYgcsB3YoA06Io9QhLHAAuBswhvH7wK2AVtUaXVsS/QQKcWc33mYsEEU6Mac802oHnNtjC0SIboFr3YWcB4wjdOPy7piH/CmNmjjcP6TCDOBhZjPHCWagM0Y79fHAgdCpBY4HzgHKHdszUjYC2xAhzfHo0isRbcgtnOBJYTbqx0qrcAGYJs29H9bJkIZxotfgEmCRZkuYBPwhiqJjgOehEglZl7PI16lnweAl1E94NqQoIit6Epa5gBLib7w9EcXsA7YrA3mBIqQwgjtEqLt8fRHF/AqsCnxcV+RMoxnez7hyEMERSNGfGOXbI2d6EpaJgCXYEq+4k4LsJq/03Lgg8TzAnMibcAaVd52bYgTRM4BLsKULiYBxcR8V8ep4iE2oitpqQQ+gAknJIPeCb003drLzkuFtYvK6a4IuuQrLOwFXkpMwk2kBvgQpuokiWSA5+IS742F6EpapgMfISkegIrS8u8ytFxXBWUmntdbkuPVed3snBWH2PVQyAOvqPK6a0MCxXi3FxO/kNHpsA14Kepeb6RFV9IiwIXABUSvIuH0yNZl2ZvO0n1m//WX+ydkeP7CSnKlcUquDEQj8Iwq3a4NKSomUXYZyfVuT0UGeAbVfa4NOV0iK7qSlmrgckyxfzLInNfNvjtKyI8auNa2vaqHpz8odNTEOdFyIu3AU6pYXdEXGCLjgE/gfvFKWFFgDapvujbkdIik6EpapmEENxnhBICjn87Q/OVKGKIHmy3J8eKSHpomJeVvlAfWqhLJL+I7iMzGhMqScsEcCduA51GNVEVL5ERX0rIEE1JIRjhBS5T9f9tF24rhi6eibDw7w5vn1gRgWVjZBTwbybpekSWYMkfP0DkIPIlqxrUhQyVSoitpWY5ZYZUMsnVZ9tyZo2dGxYjeZ/+EDM8trSRfkpQ47yFgVWSEVySFid/OcWxJVGkHVkVlKXFkRFfScjGm+D8ZZEfn2H13juzE4mStD43t5P8tr0RTybhDMG0FV4U+wWYE96PAGa5NiTgZjPAedW3IYETC85G0rCBJgpurzdH4k2zRBBdgwtEqLlvbBRG5yo6ceuAvRELXZetdjOB+DC+4xaAa+HQhCRlqQi+6kpYPY9aXJ4NcdY7dP8nSO3lkIYX+mNxSxaUvdyZIeMcTVuE1vW4vxzRi8hSHSuAqROpcGzIQoQ0vFGpwP4xp3pIM8pV5dt/VO+IY7mDsmZzhhaVJWUQBcAQTauh0bcg7iFyG6QzmKT4dwCOodrg2pD/C7OkuI1GCW5Fn93/vCVxwAWYcqGb5nyOT7S0C44BPFZq4u0ekrxWjJxhqgI8XegyHjlCKrqRlNrDYsRl22XdHNz2z7d0Gz26qZuHWUHoCATEe07/ALSIzME1rPMEyAXOnHDpCJ7qSljGY8pnk0PJXGTKL7C9imLe9mgktodgY0xJnizjMD5hY40dJSo25e+Ygsti1Ee8nVKIraSnFZHOT09yj8+xuDt/gZtVYCmHF+hLKeyK1omeEXCzioO2nSDlmaW9y5nY4WIZIqPpXhEp0MbcDoS/5KBq56hz77khBiTvPp7KnjA+ti8YiguKQAq5wUNFwCTDG8pgec1dxGSKhWQ4fGtGVtCwgaStymr7fQ26s+zX2E49UMX9bkuK7tcDlIpZu80VmYTaN9LihkjDE8wuEQnQlLWMxOx8kh5br3MRxT8WCbdWMPxru1VvFZTo2krUiFYToC59gZiMSCqcuFKILrCA8tgRPz5ReDl8froJ9E98VJB/Owu1gWCIS+BZHFxOPTVHjwMVhCDM4FzpJyznAFNd2WOXAN3NDbtFok+qucuZvT1L9bgkm1hoMJoHjwwrhoRLj4DnF6Rdf0tK3oWJyaFveSef8cHm5JzJvZxVVXf1u7x5TZogE0PvA9FVYXvT39YyUMxBx6uS59rYugBCuiw8KLVEO/sdwbx5Zkk+x9M0kVTMAXFTYwr6YnIOvVggrH3A5uDPRlbSMIkmdwwCOXt1Jbnz46zSnHaxizPEkCe9oYH7R3s0sP/XNyMPLpMIOHU5w6ekuw8TUkkG+Ik/L58IvuACCsHRDkhZMgEmqFavvxQJ88izsLCt0erOOE9EteLlnuRjbGS2f6yRfG8oGHP0y8UgV9UeSVEJWAZw34ncxK88Wjfh9PEEzFkdJTlee7jyStP5cRWn9ZPDdw4rN/O1J83bnFWHBxFwomsfsCZbzXQxqXXQlLSUkqWUjQNuKrkG3TQ8jkw9VUdGdJOGtZeRNxUfuLXtsMQ6RybYHdeHpnkWSKhYAjl4dTa8+hXDOriSFGGAku5SITMdXLESN4iVQh4gL0bX+IZ3SM6mXrrnRvcic1ei+N4RdpouctnAmZ1up+HCG7VVqVkVX0jIR01w4ORz9bLRLr6q6y5jSnKSeu3A6joFIMUITHvukMHF4qwPaJFmegJYoxy+NflJl7lt51yZY5pzT2NpnDklKDscLq1snWRNdSUuKpG013baii3xN9BJo72fi4aQl1MqB4Ta+9l5udBmDiLVYvE1PdxKQrPhg2/J4dOxKIUxtTlpCbfqQX2ligvZ3o/AUE2sXTZuiO/RJHBc658fnIjP1oGsLbDOc+ToTH1qIOrNtDWRTdKdZHMs9PVN6ydXFR3QnHI3PZxkatcOoYvChhegzCRErVUZWRFfSUkHSqhbal8WrPWJVdxk1Hb2uzbDM4N6uaeGYvLu4+CFYOo+2PN1pJO32q2NpPOK5JzKtOWmiO5S7s3Ew7EoHTziZZGMQm6KbLDrPjX6p2PuZ0uzaAttMHUKf3WTdwcUbK+fSlugmazuerjN60Or4ta2sPxqN1pTFoxyoH+Q1XnTjwzgb7R4DF11Ji0Dgm/+Fi6458Yrn9lGeLU1YvS4M3kthMFH2RIdSTMvHQLHh6dZaGic89E6NXzy3j9pMPC8op2bUKX9jkmjj7JnisUDgF1EbYpgsLxegZ2p8LzKjOpLm6Q40f+tImkMRfwK/iNqYMKf2FOJK76T4VmqMbo+vF98/A4mu35InftQEPYD3dIMgOz5+SbQ+ajvie0Hpn4GcBi+68SPwc+pFt9hoiZIbHd+6zdrOpIlujcgpN1D1ohs/Aj+nNsQhmPBChlLu5VbylKKUMI313MCjPMRlbOMKupnAzXyHSbQDsJnJPMZNtDOTuTzC53kyELt667OQKu6S2ce/dRPH9yykpKKNax9IA/D209N57Rc3oPkyRHIs/OsHmHPlLl775SLeeuIzIIpIjnnXPci5V+8omi1VnQF68YdLYeGtkC2FfAksWw+PPwpPjIcvfhUytTB9N7x4H4zJwbPj4KYvQmc1aAq+/jCkNwRg2CigtZ/nA78VtclouLMculLmj5k7AHduhupPwddaYXwdtDwO//tcyNwCH3gQrlSgArr/Ee6/AfY6/gjFIBaebjBd2SvJ8lXu4jZW8i1WcoD5vMIZnMVOPs/dlNPyntfX0cFl/BtnBCS2fWTri59omn3ZSyy9+afvee7NB65lzpWP8tlfrWTOlb9j46+vBeCcq7bwl//yAz77q5Vc8JVfsuFfbyyqLRW9AV6ox2bh5bvg0ErYtRLemA/3ngHfvhauewpab4eaDHxjhXn9bZ+CD6+H5r+He/8P3H19QIad6osYO0/3GbirGVYegDsBvg6fXARbjsIdi2DLzXAlwFw4/AL802H4wVfh97fBDW4tLxpliATaZ8SG6AYzRgoYg2k32EsJWrgFXMweznyf4AJMoY0L2U2KYLPvGsD6gbnXbKe6vuOk53s6zAWtp72KsppWAKrru5FU3++LvypO8gGGF0qAGYUWkh0lkCsxJ/qtc+FHfzbPf2E1vLj43f/TXmhS0lwFo44FZNip5nDsF4u8Dou+B6sBvgerX4PFAP/JnJQMwL+Ht49bqG+1SKDn1UZ4Ibjb0SzCXdxOFxOYybMs4+3AxhoqWmonu3/Bl3/Nmru/yVtP/BWKcOkd//jO7177+WJ2PvGXZLtHc8FX7inquKmge2h0Ccy8HY5OgA89C5ccgopOqC7sXrHwKByvM4/veRSu+ibUXg7ZcvjZ3QEZdSrRjVW5mABXwDcBPgnP/QKe74DRF8ExgKVwrKOfHM334ZK5EERYxxWBntfoeroApSi3sZJb+C5Hmc0GpgY21lBRSzm0LY9cypxPPsh1v/kvzPn4g6z9nze987vFX3qNa/+1gUVfuJctD3+m6GNLPsALS6VC80rY9F3YORueHmCL7H9aBpethvbvwk/ugdu+DL1BXBRONYdjlVR8DH58EH74OPx0FVx2F5x94u9LAIH3nPsfw7nPwopfwsM2bQ2YyItu8Iynk4lsZWOCdho+sn05i7/4KgAXfGU9nUdmn/Sauddsp6e9ntZdtZatKwJnd8KCrfCnM6G7CjKFufrmWBjdah4/vQL+Zp15fMtbkC2DDRH8rOHgkkKy8AJoWwqvvQiza+D42sJS6LUwphra+l5/P0y7E278OfxsPpwc/vL0iw3RDWZTwwPU0lJI0nVQxkHmMYEDgYw1HMTSKtnSymNsecRsqLf5obmU15oWYE2vTEALf/K3nppJPl/KmJntRR1bUwF5eK/XwvZC4rW5DN6YB/P2w5lb4XtLzPP/vBwufs08rjsCvyrs5PrQZCO657ed9LYj51RzODYLRZqgfDdU9D1+A+adD02L4PUfwXKAH8HyRfA6wLMw7ha4eSXcdzXErf1coBuximqw80bScgNBZHnfYBp/5EsoKRRhOuv4a1bxWy5nK5+gl9GU0cYE3uRr/Av7GM0v+K/kqERQUnTzdRoYS3G3F+9Y3M3elcVNYD32jf9A+4FzyPXUUlrRxswP/Y662QfZ9JvPo/kUUtLL+Tc+wFkfa+S5H36CQxuXI6kcqZIezrv2oaKWjOVRfv0XAYnu/dPg218yFUsqcNE6WLUK/lhvSsY6a2BaI7xwn6l0+M0U+PaN0F1h7vT/5iG4fVMAhj2m2k85lMhVxKRt6R+h/ga4GSAPJRfD2kfhDxuh5ir42jEYVwdHHoP/dR5klsKNG2DJaDgC0Fdi5vRDFI/7UQ3Mc7chutdjmt4kg8z8Lvb8g5VtP5yQS+V58FPxCEsNnUdV2X/SsyJXAGfaN8cTMPehGtgtq40vT6eFMcJD6eH4LgEG6C5LWpcxOPUczli1wmOD3iAFF+yI7nELY4SHsuZSyMUm1ncSnVVJ6zKmnJA8eh9edONH4AlBL7rFRlQoOR5fb7CtOr4XlP7JqJ5yQY0X3fgR+Dm1IbpBZJPDTenh+HqD7TVJE92BnAYvuvEjFqKbLE8XoKw50JITp7TVxGpBwBAYyGnwtanxw4tuJCnfH19vsK0m3onCkxlo/h6DgHt5eGxzJOgBbIhuBwEXG4eOsn3xLalqr45vr+D+ObXoquax8CX1WOVQ0AMELg7aoErSvN2q7fH0BntKs3RXxPOznZrWQX4f+JfUY40sg5/vEWPLI2uyNE44qNhdTqojfhUMh8b1uDbBMt3QT5vQ93LYhiEeKxwm6NVi2BPdOHSUHx5Vm+MnUE0TXVtgmybVQfsreNGND1bOpU1PN77Jpf6oWefaguLTNDHQjvohZCjOwhGgN2hDPFY4aGMQK6KrDdpD/DoRDUzty/ESqExFD5nqeH2mwdk36CtMMi15d3LxIw/ssTGQzSx7siZm2aEySo7GxwNqHh+/GPXAHFcdcgJ4d6CWeGxwAFUrIUGboju41xA3qjfER3T3Jy6eO5z52kjSwmfxw9qF06boNgPxSy4NxKgX47F6Ky95miYWf5PLcDP0OzPVLizFAz2BET/R1QbNA2/ZGi8U1K6pJNUe/dvyA/Vd9JQnqT63m+HH93YFYIfHDkdRtbaWwPbKqSC6+ocXyQmjn+52bcaI2XJmfFfY9c9WVYZ7sdxB0lZexodtNgez+mXSBj1M0m7Dxj1SDkHunhswmcoeDk6I704YJ6OcjnOgmsF7u1EkB2y1OaALD2ajgzHdUXaojKrNxd2HzSbbZ0U/PDI89gyjauH9JOtOLh7sLMTkreFCdN8iaVv4jH0kmgm1vOTZMStpCbTTF07VJiys3fcUFesXSuuiW0iobbE9rlNq11ZQ0hq98rGmiUlLoB1n5AXy3tuNDodRtb5oy1WCZBNJqmsUFeoei1653MazkyS4AJuG0GthMLYC0Q0nJYs3XAzqRHS1QTuA7S7Gdsa431aRaotOfPRAfYYjdUkKLXQCm0f8Lqq9wKsjfh9P0LSgusPFwC5LgV6BYZflRJdUb4r6+6Ph7eZR1i1IWrPy9apFa1yzCWgv0nt5guFlVwM7E92Ct/umq/GdUPdYFaXN4RfePVM6aastd22GRVopZp5BNQfEsM1cbGhC1Upzm/5wXfT+GknaUVVUmHRvuAvos6kc6xckKawAsEa16AsbtuO38gkrzrxccCy62qC9wGqXNlindn0l1a+G90Kz8ezuhG3Js0uVxqK/q9mBIFlzOxrscFGxcCKuPV20QXeStLaPk+8pg97webztVT1smlPl2gyLZIGXAnt31X0krTwy3HQS5PkeIs5Ft8ALJGkr67JDZUz8ebjKivKS5/mlgERzIcfp8Ypq4AmvNfikWlh43vbqs/4Ihehqgx7HCG9yGPtoNTUvhyfM8Np5XbSOSVLybLeqhUSuaYz9XODjeAZjB6q7XBsBIRFdAG3QrSTtVmzqjyspPey+mmH/hAxbz6x2bYZFjgPPWBtNdS9Jm9vhIhRhhT5CI7oFXgQOuTbCGqnuFNN+gNP4bmdFLy9cmKQuYlngSVXrDfVX46sZXKDA02EIK/QRKtHVBs0BT5KkZZSVb5cz8T43PXfzkue5ZXmypaGaBwHzgiot1kc1K9X+SJLmdjhYXUhohobQfdm0QduBp0lSb4axv69yEt9949yuhC313axqt2H1e1BtA57CNzu3xRZUN7g24v2ETnQBtEH3Autd22GVqf9QSeU2ey0vd8zsYPOcJMVxmwlDXM+0f3RvR/w5QEiT86EUXQBt0D8DobtKBUaqN8WM71dQsTP428+3p2d45fyawMcJD4eAP6iGpCxRdRNJa+ZvlzbgSVRDeUcRWtEF0AZ9iST1Z0h1p5j53XLKdwUnvI2TM6xZnDQPd5Uq4dqrTvVFLO/NlRA6gN+jGtqNEkItugDaoKuB113bYY1Ud4pZt5ZTvqf4IrFvYoYXL0zSirODwGMOKhWGyp+Ana6NiBEZjOC2uTZkIEIvugDaoGtJUo/SVFeKmX9bSllT8YT3QH2G55ZVJWjF2QHCLbh9/RmeJmm9pYOhHfgdqsdcGzIYYs57NJC0LAWWuLbDGrnaHHv+vpfus0ZWR9s4OcNLS6rQVJIE9w9F7I8bLCICrADOc21KRDkOrAq7h9tHpEQXQNKyELiIiHjpI0ZFOfiNDMc+PvzEVx7l9fM62XJWkmK42zC1uNFrkC+yEPggkJSLYzHYBzyFarhi9gMQOdEFkLRMAj4K1Lq2xRqtH8tw8OZKKBvaxaanNMvzS7M01ydltVkWeEk14sttRaYDVwBJ6oNxumwA1oS1SuFURFJ0ASQtFcBHgJmubbFG1xk97E0LubFlA77uWG03z1xUQmdVUrbcOYZZ2huPZbYiY4BPAHWOLQkreeAFVCN5gY2s6PYhaVkELCMp4YZcbY69DT10ze2/CqFxcobVF1SSL0nG3wN2AM9HJn47VETKgQ8BZ7k2JWS0Ac+gesC1IadL5EUX3gk3XAEkp+D/yGcyHL6xAi3s8tBdlmXdgl4apyWlJCyL2WZnk2tDAkXkDEySLSnndSA2AmtRjV68/gRiIboAkpZy4EJgPknxerPjsuz/Vg+broJ1CyvoLUvKNjtvA6stNCAPByKVGOE907UpjmgD/lRYQh15YiO6fUhaxgKXAFNd22KBg8BL/J2WARcD4xzbEzStmGRZsrZ36sN4vctJTgI5j9nO/uWoe7cnEjvR7UPSMhP4APEUog5grTbojr4nRBBgLrCU+N2KdmAaIG1VTVD3uf4QKcHczS0G4lyZsgNYh+px14YUm9iKLoCkRYA5mLDDaMfmFIPDmF4UO7Wh/zIZEUown3kBMN6ibUHQjikL2hiaZjVhwSTaFgELgThVqezBeLb2ex5bItaieyKSlukYT3A20Yr5KrAbeFMbdP9w/qMIUzBfyllEp+BegUZgM7An8Z7tYIhUYzzfuUT3DiePmeMb0OHN8SiSGNHtQ9JSBZyLmaRh9n57MftqbdCGkS1vFGEU734xw1p034H5vFtU6XBtTOQQSWESbfOAyY6tGSoZzDnfjGpiznniRPdEJC3TMAI8jXB4CW2Y26tGoEkbips8ECEFTAJmYBaVuI53HweaMF5Oo/dqi4TIOMwFdhYwyrE17ycL7MXEbHdFbTVZMUi06J5IoephKjCl8NNGkiKPac7SCDRqg7ZaGPMdRKjBCPAMzIUnaC+4HSOyTUBTYkq+XGIEeFbhmOjIigzmwrob2IdqouPzXnRPgaRlHEZ8x2FKdPqO00la5DFeXStmyeqxwuMWbdBQrKQqVD+MAsb0c9Qy9JhwFiOuJx5twEFVYpeJjhQiVZg7nQmFo57iOxd5oAWT9D0EHIpzUux08KI7TAo9H/oEuAYo4b2ClMMITxbowQhsmzZE9w9dqIjou+CUFH72JSO1cPQCbap+t9tIIVKLqXKpKRzVJxyVmPOcwszxfOHoxXivGUwsvu9xK3AkiSGD4eBF1+PxeCwSpdIpj8fjiTxedD0ej8ciXnQ9Ho/HIl50PR6PxyJedD0ej8ciXnRHiIjsEpFOEWkXkaMiskpEZri2K0hE5HoRWVf4zPtF5A8issK1XR5PFPCiWxw+raq1mNVsB4F7HNsTGCLyn4H/AdyJKbSfCdwLfMahWZ4AEZFnCw5FhWtb4oAX3SKiql3AbzFNR2KHmA0TfwDcoqoPq2qHqvaq6qOqeqtr+zzFR0RmY/ZqU+Bqt9bEAy+6RURMm73PA2tc2xIQyzGrlP6va0M81vgCZj7/ArjJrSnxIE7Nj13yiIhkMcsoD2G2z44j44HDGqOtUzyD8gXgLmAtsEZEJqnqQcc2RRrv6RaHa1S1DuMFfgP4k4hEpafpcGgB6kXEX6wTQCE5Ogt4UFXXAzuB691aFX286BYRVc2p6sOYpjdxzOavBrqBaxzb4bHDTcATqnq48O8H8CGGEeM9liIiIoJJNozFbDcTK1T1mIj8N+BnhXDKE5iOU1cAH1HV25wa6CkaYtpAfg4oEZEDhacrgDoRWaSqr7uzLtp40S0Oj4pIjnf3M7tJVTc6tikQVPUnhS/h7cD9mF6564EfOjXMU2yuwdyxLcS0KO3jQUyc9zsObIoFvrWjx+M5CRF5HNioqt953/OfA34KTPcJ1dPDi67H4/FYxCfSPB6PxyJedD0ej8ciXnQ9Ho/HIl50PR6PxyJedD0ej8ciXnQ9Ho/HIl50PR6PxyJedD0ej8ciXnQ9Ho/HIv8f9BK4usIZwMAAAAAASUVORK5CYII=\n", "text/plain": [ "
" ] @@ -1050,9 +1050,9 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "py38arm", "language": "python", - "name": "python3" + "name": "py38arm" }, "language_info": { "codemirror_mode": { @@ -1064,7 +1064,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.7" + "version": "3.8.13" } }, "nbformat": 4, diff --git a/doc/other-languages.md b/doc/other-languages.md index f21fb0ee28..80bc5c8b81 100644 --- a/doc/other-languages.md +++ b/doc/other-languages.md @@ -32,8 +32,7 @@ sourmash_comp_matrix <- as.matrix(sourmash_comp_matrix) ``` -[See the output of plotting and clustering this matrix](./_static/ecoli-cmp.html), -produced by [this RMarkdown file](_static/ecoli-cmp.Rmd). +See the output of plotting and clustering this matrix by downloading and opening [this html](https://raw.githubusercontent.com/sourmash-bio/sourmash/latest/doc/_static/ecoli-cmp.html), produced by [this RMarkdown file](https://raw.githubusercontent.com/sourmash-bio/sourmash/latest/doc/_static/ecoli-cmp.Rmd). -You can download the `ecoli.cmp.csv` file itself [here](_static/ecoli.cmp.csv). +You can download the `ecoli.cmp.csv` file itself [here](https://raw.githubusercontent.com/sourmash-bio/sourmash/latest/doc/_static/ecoli.cmp.csv). diff --git a/doc/plotting-compare.ipynb b/doc/plotting-compare.ipynb index 8e1890bf33..40e6e520ad 100644 --- a/doc/plotting-compare.ipynb +++ b/doc/plotting-compare.ipynb @@ -15,7 +15,7 @@ "### Running this notebook.\n", "\n", "You can run this notebook interactively via mybinder; click on this button:\n", - "[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/dib-lab/sourmash/latest?filepath=doc%2Fplotting-compare.ipynb)\n", + "[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/dib-lab/sourmash/latest?labpath=doc%2Fplotting-compare.ipynb)\n", "\n", "A rendered version of this notebook is available at [sourmash.readthedocs.io](https://sourmash.readthedocs.io) under \"Tutorials and notebooks\".\n", "\n", @@ -51,29 +51,70 @@ "name": "stdout", "output_type": "stream", "text": [ - "\u001b[K\n", - "== This is sourmash version 3.3.2.dev9+g462bc387. ==\n", - "\u001b[K== Please cite Brown and Irber (2016), doi:10.21105/joss.00027. ==\n", - "\n", - "\u001b[Kloaded 1 sigs from '../tests/test-data/demo/SRR2060939_1.sig'g'\n", - "\u001b[Kloaded 1 sigs from '../tests/test-data/demo/SRR2060939_2.sig'g'\n", - "\u001b[Kloaded 1 sigs from '../tests/test-data/demo/SRR2241509_1.sig'g'\n", - "\u001b[Kloaded 1 sigs from '../tests/test-data/demo/SRR2255622_1.sig'g'\n", - "\u001b[Kloaded 1 sigs from '../tests/test-data/demo/SRR453566_1.sig'g'\n", - "\u001b[Kloaded 1 sigs from '../tests/test-data/demo/SRR453569_1.sig'g'\n", - "\u001b[Kloaded 1 sigs from '../tests/test-data/demo/SRR453570_1.sig'g'\n", - "\u001b[Kloaded 7 signatures total. \n", - "\u001b[K\n", - "0-SRR2060939_1.fa...\t[1. 0.356 0.078 0.086 0. 0. 0. ]\n", - "1-SRR2060939_2.fa...\t[0.356 1. 0.072 0.078 0. 0. 0. ]\n", - "2-SRR2241509_1.fa...\t[0.078 0.072 1. 0.074 0. 0. 0. ]\n", - "3-SRR2255622_1.fa...\t[0.086 0.078 0.074 1. 0. 0. 0. ]\n", - "4-SRR453566_1.fas...\t[0. 0. 0. 0. 1. 0.382 0.364]\n", - "5-SRR453569_1.fas...\t[0. 0. 0. 0. 0.382 1. 0.386]\n", - "6-SRR453570_1.fas...\t[0. 0. 0. 0. 0.364 0.386 1. ]\n", - "min similarity in matrix: 0.000\n", - "\u001b[Ksaving labels to: compare-demo.labels.txt\n", - "\u001b[Ksaving comparison matrix to: compare-demo\n" + "\r", + "\u001b[K\r\n", + "== This is sourmash version 4.8.2. ==\r\n", + "\r", + "\u001b[K== Please cite Brown and Irber (2016), doi:10.21105/joss.00027. ==\r\n", + "\r\n", + "\r", + "\u001b[Kloading '../tests/test-data/demo/SRR2060939_1.sig'\r", + "\r", + "\u001b[KLoaded 1 sigs from '../tests/test-data/demo/SRR2060939_1.sig'\r", + "\r", + "\u001b[Kloading '../tests/test-data/demo/SRR2060939_2.sig'\r", + "\r", + "\u001b[K<<" + "
" ] }, - "metadata": { - "needs_background": "light" - }, + "metadata": {}, "output_type": "display_data" } ], @@ -195,7 +235,7 @@ " [0. 0. 0. 0.356 1. 0.072 0.078]\n", " [0. 0. 0. 0.078 0.072 1. 0.074]\n", " [0. 0. 0. 0.086 0.078 0.074 1. ]]\n", - "reordered labels: ['SRR2255622_1.fastq.gz', 'SRR2241509_1.fastq.gz', 'SRR2060939_2.fastq.gz', 'SRR2060939_1.fastq.gz', 'SRR453570_1.fastq.gz', 'SRR453569_1.fastq.gz', 'SRR453566_1.fastq.gz']\n" + "reordered labels: ['SRR453566_1.fastq.gz', 'SRR453569_1.fastq.gz', 'SRR453570_1.fastq.gz', 'SRR2060939_1.fastq.gz', 'SRR2060939_2.fastq.gz', 'SRR2241509_1.fastq.gz', 'SRR2255622_1.fastq.gz']\n" ] } ], @@ -215,7 +255,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "If you want to customize the plots, please see the code for `plot_composite_matrix` in [sourmash/fig.py](https://github.com/dib-lab/sourmash/blob/latest/sourmash/fig.py), which is reproduced below; you can modify the code in place to (for example) [use custom dendrogram colors](https://stackoverflow.com/questions/38153829/custom-cluster-colors-of-scipy-dendrogram-in-python-link-color-func)." + "If you want to customize the plots, please see the code for `plot_composite_matrix` in [sourmash/fig.py](https://github.com/sourmash-bio/sourmash/blob/latest/src/sourmash/fig.py), which is reproduced below; you can modify the code in place to (for example) [use custom dendrogram colors](https://stackoverflow.com/questions/38153829/custom-cluster-colors-of-scipy-dendrogram-in-python-link-color-func)." ] }, { @@ -290,14 +330,12 @@ "outputs": [ { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAr4AAAFxCAYAAACV0auJAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAADh0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uMy4xLjIsIGh0dHA6Ly9tYXRwbG90bGliLm9yZy8li6FKAAAgAElEQVR4nOzde7ycZXnv/8+Xc0vAIFGUgyAnUamkAspW1HRbFfzJtoj1gKdQRa1lV7dH1CJYe6BWLFZFRFEogohnVBSsSlUQJdagRMEigkQJEM7BgBCu3x/PvWQc1lpZa0iYNVmf9+v1vFjP3Ifnngkrueaa67knVYUkSZK0rltv2AuQJEmS7g8GvpIkSZoVDHwlSZI0Kxj4SpIkaVYw8JUkSdKsYOArSZKkWcHAV5IkSTNOko8luTbJxRO0J8m/J7ksyY+TPHZ1cxr4SpIkaSY6Cdhvkvb9gV3a8UrgQ6ub0MBXkiRJM05VfRu4YZIuzwb+ozoXAHOTPHSyOTeYrHHevHm1ww47THuh0n31wx/+cHlVPWjY69D9a968LWqHHbYZ9jK0Dru77hz2EtagDHsBGsfin1w77CWsMXXnjZP+W7z+Hz20atUdg869BLi956ETquqEaU6zDXBVz/nS9tjVEw2YNPDdYYcdWLRo0TTXIN13Sa4c9hp0/9thh21YtOhzw16G1mG33rl02EtYY9ab/J/w0ZJ1J4ift+Nxw17CGnP7VadP+m9xrbqDjR/yjEHnvr2q9hpo8D3G+x+nJhuwDv3WSJIk6X6TkAy1anYpsF3P+bbAbyYbYI2vJEmSpi1AWG+gYw05E3hp291hH+DmqpqwzAHM+EqSJGkGSvJJYAEwL8lS4EhgQ4CqOh44C3gmcBnwW+CQ1c1p4CtJkqQBrN1Sh6p64WraC/ib6cxp4CtJkqSBDLnGd9oMfCVJkjSQjNiOHKMVpkuSJEkDMuMrSZKkAYRRy6Ea+EqSJGkg1vhKkiRpnZeMXuA7WquVJEmSBmTGV5IkSQPImvwWtvuFga8kSZIGYqmDJEmSNAOZ8ZUkSdIA1u5XFq8NBr6SJEkaiIGvJEmS1nmhu71tlIxWmC5JkiQNyIyvJEmSBjB6Nb6jtVpJQ5XkwCRXJVmR5E+HvR5J0hC1b24b5BgWA1+NpCT7Jjk/yc1JbkhyXpK9kyxMsqoFZrckuSjJs3rG7ZCkWvuKJFckObynfeMkJya5MsmtSX6UZP+e9n2SfL1d87okn07y0J72o5Lc2TP/iiQ79rRfkWRlT9s5fc/rQUlOS3JTkhuTnNrT9p4k/9PWdUmSl/a07Zrki21NNyQ5O8kjpvA67t76Lk9SU3jp3wMcVlVzqupHU+g/0XUryc6DjpckzQwGvtJalmRz4MvA+4EHAtsA7wTuaF2+V1VzgLnAccDpSeb2TTO39XkucESSp7XHNwCuAp4CPAA4AjgjyQ6tfQvgBGAHYHvgVuDjfXN/qgWGY8flfe0H9LQ9va/tc8CyNveD6QLNMbcBB7R1vQx4X5InjD0f4EzgEcBWwA+AL7J6dwJnAC+fQl/aupZMsa8kSTPKjKnxPeEEOO20Ya9CI2JXgKr6ZDtfCZwDkOSxY52q6u4kpwDHA7sAF/ZPVFWLkiwB5gNfr6rbgKN6unw5yS+BPYErquqrveOTfAD4rzXxpJI8HdgOWFBVq9rDv8+qVtWRPd2/n+Q7wP8Czq+qH9AFu2Nz/Rvwd0m2rKrrJ7pmVV0KXLq67GuSjYHrgfWBi5Isq6qdWrb8ULog/Srg7VX1+TZmZ+BEutf2TuAbVfX8JN9u017Usswvr6pPJXnTnns+evIXSZI0g4RRy6HOmNWedhosXjzsVWhE/BxYleTkJPsn2WK8TknWBw6hC7qunKDPPsDuwGUTtG9FF2hPlOV88jhtB7RygyVJ/nqcMae2koRzkuzR8/g+wKXAyUmuT3JhkqdMsK4/AvZezbqWTRb0TkdV3dEy5AB7VNVO7edfAE+iy0K/E/hET+nHu+jekGwBbEuXoaeqntwzz5wW9O4HvHFNrFWSdP8ZtVKHGZPxBZg/H849d9ir0EyQSbYFrKpbkuwLvAX4CPCQJGfRZR4B9klyE7ApcBfw4qq6tm+a5S2LuQlwDPCFe68hGwKnAidX1SXjtD8GeAfw7J6Hz6ArhbgGeDzw2SQ39WSnXwT8N93b5NcCZyfZrapuogsOnw68gi5gPwj4YpKdq2p53+WPBy4Czh5nXdsCHwRe39+2plXVp3tOP5XkrcDj6Mos7qQrjdi6qpYC351kqufRlYy8ZW2tVZK0ZsVdHaT7R1X9rKoWVtW2dBnbrYFjW/MFVTWXLtN4Jl1Gst88YA5dlnEBsGFvY7rf5FOA3wGH9Q9uH+N/FXhtVX2nZ10/rarfVNWqqjofeB9dHfFY+3lVtbKqfltV/wzc1LO+lXTlFCdW1Z1VdTpd+cAT+679r+05P6+qqq/tQXRZ1uN6gu21JslLkyxuN+Pd1NY1rzW/mS7A/0HLfv/VJFNtTfdcJUlaawx8NfJaNvYkuqCr9/EVwGuAl4y39VYLTo8Bbm/9AEgSutrUrYCDqurO3nFJtgf+E3hXVZ2yuuXBpF9r09v+43Y+oSTvBPYHnl5Vt/S1bUEX9J5ZVf+4mnXdZ+11+AjdG4Mt25uNi2nPp6qWVdWhVbU18CrguElqia+mq2+WJI2QsN5Ax7AY+GrkJNktyRvaR/ok2Q54IXBBf99W4/pRupKEiRwNvDnJJu38Q8Aj6XZfWNl37W2AbwIfrKrjx1nbs5Nskc7jgL+l7a6Q5GFJnphkoySbJHkTXXb0vDb888AWSV6WZP0kz6XbseK8Nv6twMHA0/prd9tOF2cD51XV4UxRW+cmwEbtfJNWAjIVm9IF6te1sYfQ8+YjyV+O/RkBN7a+YzftXQPseM9UnAEsnOq6JUkzw6jV+Br4ahTdSlc/+/0kt9EFvBcDb5ig/7HAM1tN7ni+QheYHdqymK+i24lgWe7Zb/dFre8r6AK2I3vaVvTM9QK6G+VuBf4D+JeqOrm1bUYXVN8I/BrYD9h/LIitqhuA/0NXfnEzcDjw7J763n8CHgb8T8+139baDqS72e2Q/OEewg+b+GUEuhrcldxzk9xKuhvsAEjy1Z5r/IGq+ildffT36ALZP+GeIJ62nu+31+dMurKQX7a2o+hu4rspyfPabhnHIkkaHQkZ8BjakvtKBP/AXnvtVYsWLbpfFrJgQfdfb24TQJIfVtVew16H7l977bV7LVr0uWEvQ+uwW+9cOuwlrDHrzaz70++bIQZCa9q8HY8b9hLWmNuvOn3Sf4s3+uNtaqudXzXQ3Et/cuRQ/p1fh35rJEmSdH9yVwdJM0orV1gxzjFuCYMkSVMRRu/mNjO+0jquqvYf9hokSesi9/GVJEmSZiQzvpIkSRrIqGV8DXwlSZI0gAy1XncQBr6SJEkazIhlfEdrtZIkSdKAzPhKkiRp+mKNryRJkmaBMNyvHx7EaIXpkiRJ0oDM+EqSJGkg7uogSZKkWcEaX0mSJM0CAWt8JUmSpJnHjK8kSZKmL4xcCtXAV5IkSYOx1GH6TjgBFi8e9iokSZI0Lclgx5DMiIzvaafBzTcPexWSpHXdejPjn701Y8QybZOZt+Nxw17CGrP88tcMewlrzJwNTx/2Eta4GfM3wAMeMOwVSJIkaVpmRO3A1M2YwFeSJEkjJFAj9smDga8kSZIGM1px76glqCVJkqTBmPGVJEnSYNYbrZSvga8kSZIGMHpfWWzgK0mSpOkL1vhKkiRJM5EZX0mSJA3GGl9JkiTNCtb4SpIkaVYYrbjXGl9JkiTNDmZ8JUmSNH3BGl9JkiTNEqMV9xr4SpIkaTA1Yje3WeMrSZKkGSfJfkkuTXJZksPHaX9Aki8luSjJkiSHrG5OM76SJEmavmSt1fgmWR/4IPA0YClwYZIzq+qnPd3+BvhpVR2Q5EHApUlOrarfTTSvGV9JkiQNJgMeq/c44LKqurwFsqcDz+7rU8BmSQLMAW4A7ppsUgNfSZIk3d/mJVnUc7yyr30b4Kqe86XtsV4fAB4J/Ab4CfDaqrp7sota6iBpypIcCPw7sAXwpKr60ZCXJEkapsFvblteVXtNNvM4j1Xf+TOAxcD/BnYCvp7kO1V1y0STmvHVSEqyb5Lzk9yc5IYk5yXZO8nCJKuSrEhySyt4f1bPuB2SVGtfkeSK3oL5JBsnOTHJlUluTfKjJPv3tO+T5Ovtmtcl+XSSh46zvo2SXJJkad/j70rykyR3JTmqr21Bkrt71rYiycv61vax9ryWJXl93/gDklzcxp2f5FFTeB13T3J2kuVJ+v9CGc97gMOqas59CXrbn8HOg46XJM0Q62WwY/WWAtv1nG9Ll9ntdQjwuepcBvwS2G3S5U7jqUkzQpLNgS8D7wceSPfRxzuBO1qX71XVHGAucBxwepK5fdPMbX2eCxyR5Gnt8Q3oPlp5CvAA4AjgjCQ7tPYtgBOAHYDtgVuBj4+zzDcB147z+GXAm4GvTPD0ftOCyrHj5J62o4Bd2nX/DHhzkv3aa7ILcCrw6va8vwScmWR1n+rcCZwBvHw1/cZsDyyZYl9J0rps0PreqSWJLwR2SfLwJBsBLwDO7OvzK+CpAEm2Ah4BXD7ZpDOq1GHxYliwYNir0AjYFaCqPtnOVwLnACR57Finqro7ySnA8XQB44X9E1XVoiRLgPnA16vqNroAc8yXk/wS2BO4oqq+2js+yQeA/+p77OHAi4HXAx/pu97Jrc+LpveUAXgpcEhV3QjcmOQjwELga3Qf93ynqr7b5v8X4B10Afw3Jpqwqi6luwt20uxrko2B64H1gYuSLKuqnVq2/FDgwXRvGN5eVZ9vY3YGTqR7be8EvlFVz0/y7TbtRS3L/PKq+lSSN+2556On/6pIktY5VXVXksOAs+n+7flYVS1J8urWfjzwLuCkJD+hC6ffUlXLJ5t3xgS+W20FD73XB8bSuH4OrEpyMt1dnhe0YPAPtK1QDqELuq4cb6Ik+wC7A/88QftWdIH2RFnOJ4/T9n7gbXQB+XQ9OMk1wG+BLwB/V1W3JdkC2Bq4qKfvRcBfjC2VP3wPPXa+O5MEvlNVVXcAc1qgukf7SAngF8CTgGXAXwKfSLJzVV1N9xfSOXTZ6Y2AvdpcT+6fp2Wu33hf1ylJup+txS+wqKqzgLP6Hju+5+ffAE+fzpwzJvB96EPh3HOHvQrNFJP9HlXVLUn2Bd5Cl1F9SJKz6DKPAPskuQnYlG5bkxdXVX/ZwfKWxdwEOIYuyOxbQzakKx84uaouGaf9MXRZ1Wf3PHYgsEFVfT7Jgqk929+7hC47egldScHJwHuBV9Ft0wJwc0//m4HN2s9fB45u1zyf7rXZCPjjaa5hWqrq0z2nn0ryVrotaL5I94Zje2DrqloKfHeSqZ5HVzLylrW1VknSmpa1GviuDdb4aiRV1c+qamFVbUuX1dwaOLY1X1BVc+nqcc+ky0j2m0cXTL4RWABs2NuYZD3gFOB3wGH9g9vH+F+l2zrlO+2xTYF3A/93wOe0rKp+WlV3V9Uv6WqBn9uaV7T/bt4zZHO6GmNaYP4yuq1drm7P76d0NwesNUlemmRxkpvam43d27Vp6w/wg3TfqPNXk0y1NX+4bY0kaaYLXSQ5yDEkBr4aeS3oO4ku6Op9fAXwGuAlSf50nHGrquoY4PbWD4AkoatN3Qo4qKru7B2XZHvgP4F3VdUpPU270N309p0ky4DPAQ9tOzDsMMhTo5UvtFKOq4E9etr3oKfMoqo+U1W7V9WWwJF02dZ71TWvKe11+AjdG4Mt25uNi3vWvKyqDq2qremy1sdNUkt8NX94964kSWucga9GTpLdkrwhybbtfDvghcAF/X2r6nrgo3QlCRM5mm6HhE3a+YfoNsQ+oKr+oE43yTbAN4EP9tYZNRfTBW/z2/EK4Jr281Vt/IbtOusBGyTZpNUij21n9rB0tmvr+mLP/P8B/F2SLZLsRlfacVLP2vZMsn66r238MPCl8Uo0+p5P2no2auebtBKQqdiULji/ro09hJ43H0n+cuzPCLix9V3Vzq8BduyZ6wy6G/UkSaMkGewYEgNfjaJbgccD309yG13AezHwhgn6Hws8s9XkjucrdIHZoS2L+Sq6YHVZz366Y7swvIIuYDuyd79d6O5AbVnOZVW1jO6rE+9u52MB30fobnp7IfD29vNLWttjge8Bt9HV6V4M/G3POo+ku5nsSrqdJP61qr7W0/4+4Cbg0vbfQ1m97dsaxjLHK9t4AJJ8NcnbxhvYvi/9mLbma4A/Ac7r6bI33Z/RCrqSk9e2Eg7ods44uZVIPK/tlnEskqTRsva2M1srUjXxnvV77bVXLVq0aK0vYmwLM29u05gkP1zNN7poHbTXXrvXokWfG/YytA677c5lw17CmjNiNxVNZt6Oxw17CWvM8stfs/pOI2LOhk+e9N/ijR+4Y237jH8YaO7LT3/RUP6dN+MrSZKkWcHAV1rHtXKFFeMc45YwSJI0JWHkanxnzD6+ktaOqtp/2GuQJK2jRqzixsBXkiRJg1lvtCJfSx0kSZI0K5jxlSRJ0gBG7yuLDXwlSZI0fUPek3cQBr6SJEkajDW+kiRJ0sxjxleSJEmDGbGMr4GvJEmSpi9QoxX3GvhKkiRpQCOW8bXGV5IkSbOCGV9JkiQNxn18JUmStM5LLHWQJEmSZiIzvpIkSRrMiKVQBw58TzgBTjttzSxi8WKYP3/NzCVJkqT7yWyp8T3tNANWSdJo+eDPbh/2EtaYI5/18WEvYY1Zfvlrhr2ENadq2Cu4/4SRq/G9T6UO8+fDuefe90UsWHDf55AkSZImY42vJEmSBlKzpdRBkiRJs9xsublNkiRJs9gI1viOWJwuSZIkDcaMryRJkgaQ2bOdmSRJkma5ESt1MPCVJEnSYEYr7rXGV5IkSbODGV9JkiRNX6AsdZAkSdKsYOArSZKkWWHEdnWwxleSJEmzghlfSZIkTV8YuRSqga8kSZIG4BdYSJIkabYYsZvbRixBLUmSJA3GjK8kSZKmL4xcxtfAV5IkSQMpa3wlSZI0K4xY0eyILVfSMCU5MMlVSVYk+dNhr0eSpOkw8NVISrJvkvOT3JzkhiTnJdk7ycIkq1pgdkuSi5I8q2fcDkmqta9IckWSw3vaN05yYpIrk9ya5EdJ9u+79lOTXJLkt0m+lWT7vvbHJvl2m/+aJK/tu/632thLkvx5T1uSvD3Jr9raT0+yeU/7u1vQeUtb39v7rntAkovbdc9P8qgpvI4vS/LDNufSdo3JPgl6D3BYVc2pqh+tbv5JrltJdh50vCRpBgjddmaDHENi4KuR04LBLwPvBx4IbAO8E7ijdfleVc0B5gLHAacnmds3zdzW57nAEUme1h7fALgKeArwAOAI4IwkO7RrzwM+1x5/ILAI+FTP2uYBXwM+DGwJ7Ayc03PdTwI/am1vBz6T5EGt7aXAS4AnAlsDf9Se45gTgd2qanPgCcDBSZ7TrrsLcCrw6va8vwScuZogFuCPgdcB84DHA08F3jhJ/+2BJauZU5I0K6S7uW2QY0is8Z2qE06A004b9irU2RWgqj7ZzlfSgsskjx3rVFV3JzkFOB7YBbiwf6KqWpRkCTAf+HpV3QYc1dPly0l+CewJXAE8B1hSVZ9u1zsKWJ5kt6q6BHg9cHZVndrG3wH8rPXdFXgs8PSqWgl8NsnrgIPaGg8ATqyqq1r/fwG+meSvq+q3VXVp3/LvpgusAZ4BfKeqvtsz9h10Afw3Jnohq+pDPae/TnIq8Gf9/ZJsDFwPrA9clGRZVe3UsuWHAg+me8Pw9qr6fBuzM12wPh+4E/hGVT0/ybfbtBclKeDlVfWpJG/ac89HT7RUSZLuMzO+U3XaabB48bBXoc7PgVVJTk6yf5ItxuuUZH3gELqg68oJ+uwD7A5cNkH7VnSB9liW89HARWPtLVD+RXscYB/ghlZqcG2SLyV5WM/Yy6vq1p5LXNQzNu2g53xjuqB9bD2HJ1kBLAU2BU6bZGzac5uOJzNORreq7mgZcoA9qmqn9vMvgCfRZcffCXwiyUNb27vo3pBsAWxLy15X1ZN75pnTgt79mDzTLEmaicz4rsPmz4dzzx32KmaHSep/quqWJPsCbwE+AjwkyVl0mUeAfZLcRBcY3gW8uKqu7ZtmectibgIcA3zh3kvIhnTlAye3bC7AHOC6vq43A5u1n7ely+o+DfgJ8G668oYntrE3jzN2m/bzV4E3JzkDuLE9P+jKEcae+9Etmzsf+Iue+b4OHJ1kAXB+G7tR79jVSXIIsBfwiqmOGct8N59K8lbgccAX6d5wbA9sXVVLge9OMtXzgI9zz3OWJM10/SmXEWDGVyOpqn5WVQuralu6rObWwLGt+YKqmkuXaTyTLiPZbx5dIPpGYAGwYW9jkvWAU4DfAYf1NK0ANucPbQ6MZXFXAp+vqgur6na6LOgTkjxgCmM/Rhckn0uXdf1We3xp33OvdmPZyjY/LTB/GfAB4Or2/H7aP3YiSf4COBrYv6qWT2VMG/fSJIuT3NTebOzerg3wZrq/En+QZEmSv5pkqq3pSiUkSSOigFovAx3DYuCrkdeCvpPo+1i/qlYArwFeknG23qqqVVV1DHB76wd0uyvQ1aZuBRxUVXf2DFsC7NHTd1NgJ+4pD/gx3d8Fv7/MWNfWZ8ckm/W07zE2tqrurqojq2qHFtAvAX7djvFs0K499nw+U1W7V9WWwJF02dZ71TX3a2UGHwEOqKqfrK5/z7jt27jDgC3bm42L23OlqpZV1aFVtTXwKuC4SXZyuBrYbqrXliRpEAa+GjlJdkvyhiTbtvPtgBcCF/T3rarrgY/S3eg1kaPpSgw2aecfAh5JFwiu7Ov7eWD3JAe1/u8AftxTCvFx4MAk81upxBHAd6vqpqr6ObAYODLJJkkOBB4DfLY9jwcm2alta/Yo4L3A37eb9NZL8qokW7T2xwF/Q8+Na0n2TLJ+2yXiw8CXetY10Wv5v+nKOQ6qqh9M1nccm9IF9te1uQ6h581Hkr8c+zOiK90oYFU7vwbYsWeuM4CF07y+JGnY3M5MWutupdt66/tJbqMLeC8G3jBB/2OBZyZ5zATtX6ELzA5tWcxX0dXQLss9+/2+CKCqrqPbheEf25jHAy8Ym6iqvgm8rc15Ld2uCwf3XOsFdHW0N9IF3M9tc0JXInAWcBtdve/HquqEnrEH0t1MdivwCbqbxXq3O3sfcBNwafvvoazeEXQ3pp3V81y/OtaY5KtJ3jbewKr6KV199PfoAtk/Ac7r6bI33Z/RCrqSk9dW1S9b21HAya1E4nlV9VXuKVWRJI2C4M1t0tpWVb+muxlqPCe1o7f/UrrdEcakr724Z2eFe7WPc/3/BHabpP1DdFnj8dquoKspHq/t58AjJmi7G9hvNevad7L2Ccbca+uyvvb9+877X7u30+1HPN7YN9PV+Y7XdjzdFm69jx291167//MUli1Jmim8uU2SJEmaeQx8pXVcK1dYMc4xbgmDJElTEWC99QY7hsVSB2kd11+uIEnSmjLE+9QGYuArSZKk6RvuBg0DsdRBkiRJM06S/ZJcmuSyJIdP0GdB+yKlJUn+a3VzmvGVJEnSAELWUso3yfrAB4Gn0X0L6YVJzmxbaY71mQscB+xXVb9K8uDVzWvGV5IkSdMW1ur3VzwOuKyqLq+q3wGnA8/u63Mw8Lmq+hVAVV27ukkNfCVJkjSQ+xD4zkuyqOd4Zd/U2wBX9ZwvbY/12hXYIsm5SX6Y5KWrW6+lDpIkSbq/La+qvSZpHy8vXH3nGwB7Ak8F/gj4XpIL2hdCjcvAV5IkSdMXyNqrHVgKbNdzvi3wm3H6LK+q24Dbknwb2AOYMPC11EGSJEkDWYs1vhcCuyR5eJKNgBcAZ/b1+SLwpCQbJPlj4PHAzyab1IyvJEmSpi3AemtpH9+quivJYcDZwPrAx6pqSZJXt/bjq+pnSb4G/Bi4G/hoVV082bwGvpIkSZpxquos4Ky+x47vO/9X4F+nOqeBryRJkgYyat/cZuArSZKkgRj4SpIkad0X1to3t60tMybwXbwYFiwY9iomsfhYzp3/umGvQpIkSQOaEYHvwQcPewWSpNngyGd9fNhLWGPe+eVDhr2ENaf6v5dgdN3NXcNewv1qLe7ju1bMiMD3la/sjhltgdleSZKkMWH0anxHLE6XJEmSBjMjMr6SJEkaMVP/FrYZw8BXkiRJAzHwlSRJ0qywtr6yeG2xxleSJEmzghlfSZIkTdso7upg4CtJkqSBGPhKkiRp3RfIiBX5WuMrSZKkWcGMryRJkgZiqYMkSZJmBQNfSZIkrfNGcVcHa3wlSZI0K5jxlSRJ0vRl9L65zcBXkiRJAxm1UgcDX0mSJA0kI1Y0O2LLlSRJkgZjxleSJEnTNoq7Ohj4SpIkafoCGbHI11IHSVOW5MAkVyVZkeRPh70eSdJwJYMdw2Lgq5GUZN8k5ye5OckNSc5LsneShUlWtcDsliQXJXlWz7gdklRrX5HkiiSH97RvnOTEJFcmuTXJj5Ls33ftpya5JMlvk3wryfZ97Y9N8u02/zVJXtt3/W+1sZck+fOetiR5e5JftbWfnmTznvZ3t6Dzlra+t/dd94AkF7frnp/kUVN4HXdPcnaS5UlqCi/9e4DDqmpOVf1oCv0num4l2XnQ8ZIkDcLAVyOnBYNfBt4PPBDYBngncEfr8r2qmgPMBY4DTk8yt2+aua3Pc4EjkjytPb4BcBXwFOABwBHAGUl2aNeeB3yuPf5AYBHwqZ61zQO+BnwY2BLYGTin57qfBH7U2t4OfCbJg1rbS4GXAE8Etgb+qD3HMScCu1XV5sATgIOTPKdddxfgVODV7Xl/CTgzyerKme4EzgBevpp+Y7YHlkyxryRpHTdqGV9rfKdj8WJYsGDYqxDsClBVn2znK2nBZZLHjnWqqruTnAIcD+wCXNg/UVUtSrIEmA98vapuA47q6fLlJL8E9gSuAJ4DLKmqT7frHQUsT7JbVV0CvB44u6pObePvAH7W+u4KPBZ4elWtBD6b5HXAQW2NBwAnVtVVrf+/AN9M8tdV9duqurRv+eWHJzAAACAASURBVHfTBdYAzwC+U1Xf7Rn7DroA/hsTvZBtzktXl31NsjFwPbA+cFGSZVW1U8uWHwo8mO4Nw9ur6vNtzM50wfp8ugD7G1X1/CTfbtNe1LLML6+qTyV50557PnqyZUiSZpgRK/E14ztlBx8M8+cPexXq/BxYleTkJPsn2WK8TknWBw6hC7qunKDPPsDuwGUTtG9FF2iPZTkfDVw01t4C5V+0xwH2AW5opQbXJvlSkof1jL28qm7tucRFPWPTDnrON6YL2sfWc3iSFcBSYFPgtEnGpj23+6yq7mgZcoA9qmqn9vMvgCfRZcffCXwiyUNb27vo3pBsAWxLy15X1ZN75pnTgt79gDeuibVKku4fofvmtkGOYTHjO1WvfGV36P4xyVvIqrolyb7AW4CPAA9JchZd5hFgnyQ30QWGdwEvrqpr+6ZZ3rKYmwDHAF+49xKyIV35wMktmwswB7iur+vNwGbt523psrpPA34CvJuuvOGJbezN44zdpv38VeDNSc4AbmzPD+CPe5770S2bOx/4i575vg4cnWQBcH4bu1Hv2LVhLPPdfCrJW4HHAV+ke8OxPbB1VS0FvjvJVM8DPs49z1mSpDXOjK9GUlX9rKoWVtW2dFnNrYFjW/MFVTWXLtN4Jl1Gst88ukD0jcACYMPexiTrAacAvwMO62laAWzOH9ocGMvirgQ+X1UXVtXtdFnQJyR5wBTGfowuSD6XLsP8rfb40r7nXu3GspVtflpg/jLgA8DV7fn9tH/smpbkpUkWJ7mpvdnYvV0b4M10CYEfJFmS5K8mmWprulIJSdKoGDDbO8yMr4GvRl4L+k6i72P9qloBvAZ4yXhbb1XVqqo6Bri99QO63RXoalO3Ag6qqjt7hi0B9ujpuymwE/eUQvwY6N0dYezntD47Jtmsp32PsbFVdXdVHVlVO7SAfgnw63aMZ4N27bHn85mq2r2qtgSOpMu23quueU1pu1l8hO6NwZbtzcbFtJKLqlpWVYdW1dbAq4DjJqklvhrYbm2tVZK05nWlDjXQMSwGvho5SXZL8oYk27bz7YAXAhf0962q64GP0t3oNZGj6UoMNmnnHwIeCRzQbkLr9Xlg9yQHtf7vAH7cUwrxceDAJPNbqcQRwHer6qaq+jmwGDgyySZJDgQeA3y2PY8HJtmpbWv2KOC9wN+3m/TWS/KqJFu09scBf0PPjWtJ9kyyftsl4sPAl3rWNdFrmfY8Nmrnm7QSkKnYlC6wv66NPYSeNx9J/nLsz4iudKOAVe38GmDHnrnOABZO8bqSJA3EwFej6Fbg8cD3k9xGF/BeDLxhgv7HAs9M8pgJ2r9CF5gd2rKYr6KroV2We/b7fRFAVV1HtwvDP7YxjwdeMDZRVX0TeFub81q6XRcO7rnWC4C92tijgee2OaErETgLuI2u3vdjVXVCz9gD6W4muxX4BN3NYr3bnb0PuAm4tP33UFZve7qSibGM9co2HoAkX03ytvEGVtVP6eqjv0cXyP4JcF5Pl73p/oxW0JWcvLaqftnajgJObiUSz6uqr3JPqYokaUSMWqmDN7dp5FTVr+luhhrPSe3o7b+UbneEMelrL+7ZWeFe7eNc/z+B3SZp/xBd1ni8tivoaorHa/s58IgJ2u4G9lvNuvadrH2S9Uz4fKtq/77z/tfu7XT7EY839s10db7jtR1Pt4Vb72NH77XX7v88pYVLkmaEUcugGvhKkiRp2sZqfEfJqAXqkqaplSusGOcYt4RBkqR1lRlfaR3XX64gSdKaMsx63UEY+EqSJGnawuiVDhj4SpIkafqGvEPDIEYtUJckSZIGYsZXkiRJA8mI7epg4CtJkqRp67YzG/YqpsfAV5IkSQMZtZrZUVuvJEmSNBAzvpIkSZq2UCP3zW0GvpIkSRqINb6SJEmaFUatZnbU1itJkiQNxIyvJEmSpi0j+M1tBr6SJEkaiDe3SZIkaZ03il9gYY2vJEmSZgUzvpJmjKt/+zv+cfGVw17GGvHbu0YsDTKJ9z7nxGEvYY1Z+at3DnsJ0jpl1DKoBr6SJEkaiDW+kiRJWudZ4ytJkiTNUGZ8JUmSNH3u4ytJkqTZIIxe6cCorVeSJEkaiIGvJEmSBrJeaqBjKpLsl+TSJJclOXySfnsnWZXkuaub01IHSZIkDWRt1fgmWR/4IPA0YClwYZIzq+qn4/T7F+DsqcxrxleSJEnTNlbjO8gxBY8DLquqy6vqd8DpwLPH6fd/gc8C105lUgNfSZIk3d/mJVnUc7yyr30b4Kqe86Xtsd9Lsg1wIHD8VC9qqYMkSZIGch9KHZZX1V6TtI83c39x8LHAW6pqVTK1hRj4SpIkaSBZe19ZvBTYrud8W+A3fX32Ak5vQe884JlJ7qqqL0w0qYGvJEmSpi1r9wssLgR2SfJw4NfAC4CDeztU1cPvWUtOAr48WdALBr6SJEmaYarqriSH0e3WsD7wsapakuTVrX3Kdb29DHwlSZI0kLW5S0JVnQWc1ffYuAFvVS2cypwGvpIkSZq2MPUvo5gpDHwlSZI0kLVY47tWuI+vJEmSZgUzvpIkSRqIGV9JIyvJVkm+neTWJMcMez2SpJkrdNstDHIMi4GvRlKSfZOcn+TmJDckOS/J3kkWJlmVZEWSW5JclORZPeN2SFKtfUWSK5IcPsE1dklye5JPTDJ+RZIjetpfl+Tydu3fJPm3JBv0tF+RZGXP2HN62t7WN+/KJHcnmdfaN07ysTb3siSvn+JrdUKSS9tcC1fT/ZXAcmDzqnrDVOaf4JonJfmHQcdLkkbDeqmBjqGtd2hXlgaUZHPgy8D7gQfSfXf3O4E7WpfvVdUcYC5wHN23usztm2Zu6/Nc4IgkTxvnUh+k20B7PHOrak473tXz+JeAx1bV5sDuwB7A3/aNPaBn7NPHHqyqf+p5fA7wL8C5VbW8dTkK2AXYHvgz4M1J9ptgfb0uAl4D/PcU+m4P/LSqRus2XUmSpsAa3wGd8MMTOO0npw17GbPVrgBV9cl2vhI4ByDJY8c6VdXdSU4BjqcLGO8VxFbVoiRLgPnA18ceT/IC4CbgfGDnqS6sqn7Rcxrg7umM77l+gJcAf9/z8EuBQ6rqRuDGJB8BFgJfW82aPtjmvH011zwJeBFQSV4H/AVwC/A+4JF0r/NngddX1e/aGt/bxmwMXEn3rTpP6JvnW1V1QJI/BU6k+7M4i+471y+rqr+bymsiSZpZ1vI3t60VZnwHdNpPTmPxssXDXsZs9XNgVZKTk+yfZIvxOiVZHzgEuJMuKBuvzz50mdnLeh7bnC7gnOyj/iuTLE3y8bFShJ7xBye5ha5kYA/gw31jT01yXZJzkuwxwfxPAraiCzRpz3FruuztmIuAR0+yxmlpm3+fCry7ZZ3/E1gF/D+670D/X8BT6bLHAE8Hnkz3RmQu8Hzg+qo6oW+eA5JsBHwBOIUuS/9p4KA1tXZJ0nCsl8GOYTHjex/Mf8h8zl147rCXsU7KIRP/VlTVLUn2Bd4CfAR4SJKzgENbl32S3ARsCtwFvLiqru2bZnmSjYFNgGPogrIx7wJOrKqruqTmH44D9gYWA1vSlUOcCjyjZ32nAacl2YUuS3tNz/gX0ZUcBHgtcHaS3arqpr7rvAz4TFWtaOdz2n9v7ulzM7BZ/wLXpKr6Yc/pFUk+DDwFOJbuDcVmwG7AD6rqZ5NMtQ+wIXBsK6P4zFRrlCVJM1OA9c34SmtfVf2sqhZW1bZ0Gdut6YIxgAuqai6wBXAmXfa03zy6YPKNwAK6oIwk84E/B/5tguuuqKpFVXVXVV0DHAY8vWWJ+/v+D7CErs547LHzqmplVf22qv6ZrpziD9aX5I+AvwRO7nl4LADuvc7mwK3jrXNNSbJrki+3m+luAf6J7rWjqr4JfIAu+L+m3UR3r9eh2Rr4dV/t8LhZeEmS1hYDX428qroEOIkuAO59fAXdx/IvafWl/eNWVdUxwO3c8/H9AmAH4FdJltEFxgclmejGsLFAbqL3vBsAO022/HHGPge4ATi3Z603AlfTlU6M2YMusF6bPgRcAuzSbth7Gz3rrap/r6o96UoudgXeNNbUN8/VwDb5wxT6w9baqiVJ94tRK3Uw8NXISbJbkjck2badbwe8ELigv29VXQ98FHjHJFMeTbdDwibACXSB6vx2HA98hVbKkOTxSR6RZL0kWwL/Trfzws2t/RVJHtx+fhTwVuAb7fxhSZ6YZKMkmyR5E1329Ly+9bwM+I9xdlb4D+DvkmyRZDe60o6TpvB6bdSeW4AN27Wn+ru/Gd0NbivaNf+6Z9692+uxIXAb3RuIVa35GmDHnnm+R1d28rdJNkjyHOBxU1yDJGkGCm5nJt0fbgUeD3w/yW10Ae/FTHwz2rHAM5M8ZoL2rwA3Aoe2EoRlYwddicHtVXVd67sj3S4Kt7Zr3kEXdI95IvCTtq6z2vG21rYZXQb1RuDXwH7A/i04ByDJNsD/pgty+x0J/IKuROC/gH+tqkl3dGjOoduR4Ql0gf1KupvSSPKitqvFRN5It1PDrXT11J/qadu8PXZjW9P1wHta24nAo5LclOQLVfU7ukz2wtb/+cDnep73w5KsQJI0OgbM9npzmzQNVfVr4HkTNJ9EXxa0qpbSbbc1Jn3txQS7I1TVUX3nnwQ+OV7f1n7IJG1LgImC77E+v2aC38uqugP4q3ZMWVUtmKTtVLqb88bOF/a1f5vu5rVe72ht32CC59Pqm+f3PbYI+H3JSds+baztV8CcbR61q/sHS5LWGgNfSZIkTdvYVxaPEksdpBHXyhVWjHOs7RvfJEmznKUOku5X/eUKo6K/rEKSpLXNwFeSJEkDGeYODYMw8JUkSdK0jeI3txn4SpIkaSDDrNcdhDe3SZIkaVYw4ytJkqRpy5B3aBiEga8kSZIGYuArSZKkdV53c9to7epgja8kSZJmBTO+kiRJGsioZVANfCVJkjRtwRpfSZIkzRKjFviOWoZakiRJGogZX0mSJE1bUiO3q4OBryRJkgYyaqUOBr6SJEmaNm9um0UWL1s87CVI65wCbl81Yn+LTuC9zzlx2EtYY17/uZcPewmStEYY+EqSJGkgZnwlSZK0zuu+snjYq5geA19JkiRNX2C9EdvVwX18JUmSNCuY8ZUkSdJARi2DauArSZKkaXM7M0mSJM0ao3Zz26hlqCVJkqSBmPGVJEnStHWlDqO1q4OBryRJkgYyajW+ljpIkiRpVjDjK0mSpGlLRi/ja+ArSZKkgYxa6YCBryRJkgaSEcv4jlqgLkmSJA3EjK8kSZIGMmIJXwNfSZIkTV8YvVIHA19JkiQNZNRqZkdtvZIkSdJAzPhKkiRpIBmxryw24yvp95JsleTbSW5Ncsyw1yNJmtky4DEsBr4aSUn2TXJ+kpuT3JDkvCR7J1mYZFWSFUluSXJRkmf1jNshSbX2FUmuSHL4BNfYJcntST4xyfgVSY7oaX9dksvbtX+T5N+SbNDTfkWSlT1jz+m75oOSnJbkpiQ3Jjm1p22bJF9sz3dpkldP8bU6IcmlSe5OsnA13V8JLAc2r6o3TGX+Ca55UpJ/GHS8JGnmG7u5bZBjWAx8NXKSbA58GXg/8EBgG+CdwB2ty/eqag4wFzgOOD3J3L5p5rY+zwWOSPK0cS71QeDCCZYxt6rmtONdPY9/CXhsVW0O7A7sAfxt39gDesY+va/tc8AyYHvgwcB7eto+AfwS2Ar4/4B/SvJnE6yv10XAa4D/nkLf7YGfVtVofXYlSdIUWON7H6z43QoWnLRg2MuYjXYFqKpPtvOVwDkASR471qmq7k5yCnA8sAvjBLFVtSjJEmA+8PWxx5O8ALgJOB/YeaoLq6pf9JwGuHuq45M8HdgOWFBVq9rDP2ptc4AFwPOq6k7goiSfAf4K+NZq1vTBNsftq7n+ScCLgEryOuAvgFuA9wGPpHudPwu8vqp+lyTAe9uYjYErgYOBJ/TN862qOiDJnwIn0v1ZnAUUcFlV/d1UXh9J0swzYruZmfEd1FabbsWcjeYMexmz1c+BVUlOTrJ/ki3G65RkfeAQ4E66oGy8PvvQZWYv63lsc+Dvgck+6r+ylRt8PMm8vjkPTnILXcnAHsCH+8aemuS6JOck2aPn8X2AS4GTk1yf5MIkTxmbtu+/Yz/vPskap6WqFgKnAu9u2ej/BFYB/w+YB/wv4Kl02WOApwNPpnsjMhd4PnB9VZ3QN88BSTYCvgCcQpel/zRw0JpauyRpCALrDXgMixnfAT10s4fy0M0eyrkLzx32UtZJOWTi34qquiXJvsBbgI8AD0lyFnBo67JPkpuATYG7gBdX1bV90yxPsjGwCXAMXVA25l3AiVV1Ve5diLQc2BtYDGxJVw5xKvCMnvWdBpyWZBfgpcA1PeNfRFdyEOC1wNlJdquqm4Bt6YLJV9AF7AcBX0yyc1UtT3IeXVnGm4BHtfbrJnyh1oCq+mHP6RVJPgw8BTiW7g3FZsBuwA+q6meTTLUPsCFwbCuj+EyS16+lZUuS7idmfKX7QVX9rKoWVtW2dFnPremCMYALqmousAVwJvCkcaaYB8wB3khXQrAhQJL5wJ8D/zbBdVdU1aKququqrgEOA57essT9ff8HWEJXZzz22HlVtbKqfltV/0xXTjG2vpXAFVV1YlXdWVWnA1cBT2ztLwIe3h77EF3AvXQ1L9V9kmTXJF9Osqxlsf+J7rWjqr4JfIAu+L+m3UR3r9eh2Rr4dV/t8LhZeEmSAJLs127Ovmy8G9GTvCjJj9txft+nqOMy8NXIq6pLgJPo+9i/qlbQfSz/klZf2j9uVVUdA9zOPR/fLwB2AH6VZBldYHxQkoluDBsL5CZ607sBsNNky+8Z++Oe+e7dserKqnpWVT2oqh5Pl3H+wSRzrwkfAi4Bdmk37L2tZ71U1b9X1Z7Ao+lKHt401tQ3z9XANvnDFPrD1tqqJUlr3drc1aGVK34Q2J/uU84XJnlUX7dfAk+pqsfQfVp7wurmNfDVyEmyW5I3JNm2nW8HvBC4oL9vVV0PfBR4xyRTHg28OckmdL80O9Hd7Daf7sa4r9BKGZI8PskjkqyXZEvg34Fzq+rm1v6KJA9uPz8KeCvwjXb+sCRPTLJRkk1aycI84Ly2js8DWyR5WZL1kzyXbseK89r4RybZrI1/MV1ZxHun8Hpt1J5bgA3btaf6u78Z3Q1uK5LsBvx1z7x7t9djQ+A2ujcQYzflXQPs2DPP9+jKTv42yQZJngM8boprkCTNUGtxH9/H0d0AfXlV/Q44HXh2b4eqOr+qbmynF9CVDE7KwFej6Fbg8cD3k9xG9z/7xUx8M9qxwDOTPGaC9q8ANwKHthKEZWMHsAK4varGaml3BL7W1nAx3RZqL+yZ64nAT9q6zmrH21rbZnQZ1BuBXwP7Afu34JyqugH4P3RZ5puBw4FnV9XyNv4ZwOVt/KuB/XrWNZlz6MoonkAX2K+kuylt7GOiJZOMfSPdTg230tVTf6qnbfP22I10ZQvXc8/2aycCj2r7EX+h/aX1HGBh6/98uq3baOt4WJIVU3gukqQZ5D4EvvOSLOo5Xtk39TZ0pX1jlrbHJvJy4KurW683t2nkVNWvgedN0HxSO3r7L6XbbmtM+tqL7qP68a51VN/5J4FPjte3tR8ySdsSYKLge6zPd4A/maDtWO6pY56yqlowSdupdLXCY+cL+9q/TXfzWq93tLZvMMHzafXN8/seWwT8vuSkbZ821vYrYM7Wj9rV/YMlaXZYXlV7TdI+XmJ43H8j2p72Lwf2Xd1FDXwlSZI0kLW4NdlSur3tx2wL/Ka/U/s096P0fII6GUsdpBHXyhVWjHNMVsIgSdJ9MmiZwxRj5QuBXZI8vO0F/wK6nZruuX7yMLqyuZdU1c+nMqkZX2nE9ZcrjIr+sgpJ0uhJ1k6FWlXdleQw4GxgfeBjVbUkyatb+/F0pXdbAse1TYPuWk35hIGvJEmSZp6qGrtJvPex43t+fgXdlz5N2X0KfBcvhgUL7ssMo2vxsmOZf/jrhr0MSZKkoRm1b24bOPA9+OA1uQxJkiSNlCl+GcVMMnDg+8pXdsdsteAks72SJEmjxBpfSZIkTVsYve3BDHwlSZI0kFlT6iBJkqTZbcTi3pHLUEuSJEkDMeMrSZKkgVjqIEmSpFlhxOJeA19JkiRNX4D1RizytcZXkiRJs4IZX0mSJA1kxBK+Br6SJEkaRJHUsBcxLQa+kiRJGogZX0ka0LLLlvOeA08c9jLWiDd+/uXDXsIas8n6o5XRkaSJGPhKkiRp2hL38ZUkSdIsMWJxr4GvJEmSBjNq++KO2nolSZKkgZjxlSRJ0kCs8ZUkSdIsEEatytfAV5IkSdPWhb2jFfha4ytJkqRZwYyvJEmSBpKMVg7VwFeSJEkDGq1SBwNfSZIkDSDW+EqSJEkzkRlfSZIkDWi0Mr4GvpIkSRrIqN3cNlqrlSRJkgZkxleSJEkDstRBkiRJ67iM4K4OBr6SJEkayKgFvtb4SpIkaVYw8JX0e0m2SvLtJLcmOWbY65EkzXTrDXgMh4GvRlKSfZOcn+TmJDckOS/J3kkWJlmVZEWSW5JclORZPeN2SFKtfUWSK5IcPsE1dklye5JPTDJ+RZIjetpfl+Tydu3fJPm3JBv0tF+RZGXP2HP6rvmgJKcluSnJjUlO7Wv/8yT/neS2JFcled4UXqsTklya5O4kC1fT/ZXAcmDzqnrD6uae5JonJfmHQcdLkkZD8v+3d+/BVpV1GMe/jygypYRIjSlKmSjSRRIUUktmahScMRtxRsW8MClTjWNlmmZOf9SMWZNmlqkUChqmZWmG5CW7zOQt0cxLXqJG8wYpiXoUVPDpj/Weabk7Z5/jDthnu5/PmTWw1vuud/32ZvbZv/3u33pRS1u7pMY3Oo6kkcBi4DPAT4HhwIeBl0uXW23vo2pxweOAyyWNtb2qNswo22slTQH+IOlO2zc2XOo84I5+whhle20fx38FLLC9StJo4ErgBODsWp8Dbf+mn3F/Ua45DngJeF/tcU8ELgOOBm4E3gaM6mecur8AVwDfHETfccBfbXsQfSMioquJrOrQRe5efjfTF0xvdxjdaGcA2z8p+6uBGwAk7d7byfZrki4FLgDG00cSa3uppPuBSVTJJGWcw4BVwC3AToMNzPbfa7sCXhvs+ZL2A7YHptteVw7/udbldOBC278u+yvLNlBM55Xx1wxw/QXAEYAlfR74BPA88F1gV6rn+efAibZfUfWR/exyzubAo8BsYK+GcX5n+0BJHwTmU/1bLAEMLLN9+kCPISIiYn1IqUOLZr9/NpO2mdTuMLrVw8A6SQslzZS0VV+dJA0D5gCvUiVlffWZRjWruqx2bCTwNaDZV/2PSnpc0sWSxjSMOVvS81QlA7sBFzacu0jS05JukLRb7fg04CFgoaSVku6QtG9DO5LulfSUpB+XWeX1wvYxwCLgW7a3KLPS64AvAGOADwEfBT5bTtkP+AjVB5FRwKHAStvzGsY5UNJw4GrgUmA08DNg1vqKPSIi2kMt/rRLZnxbNHfyXOZOntvuMN60NKf/F4Xt5yXtA5wC/BDYRtISqrIGgGmSVgFvBdYCn7T9r4ZhnpG0OTACOIsqKev1dWC+7cf6qEN6BtgDuBvYmqocYhGwfy2+y4DLJI0HjgJW1M4/AriLajb4c8D1kiaUMoyxVMnksVQJ+yzgl5J2sv1MaT+y9HkSWAh8r4y5Qdi+s7b7iKQLgX2Bc6g+UGwJTAD+ZPuBJkNNAzYDzillFFdKOnEDhR0RERtNZ82hdla0EYXtB2wfY3ss1YzttlTJGMBttkcBWwHXUNX/NhoDbAGcBEynSsqQNAn4GPCdfq7bY3up7bW2VwDHA/uVWeLGvn8D7gd+UDt2s+3Vtl+y/Q2qcore+FYDj9ieb/tV25cDjwF719ovtv2w7R7gDOCAAZ+s/4OknSUtlrS8zGKfQfXcYfu3wPepkv8V5Sa6/3keim2BJxpqh/uchY+IiM7RaTO+SXyj49l+EFhA7UawcryH6mv5I0t9aeN562yfBazhv1/fTwfeBfxT0nKqxHiWpLv6u3z5s79X8abAe5qFXzv3ntp4fRmofUM4H3gQGG97JHAatcdq+1zbk4H3UpU8nNzb1DDOU8B2ev0U+g4bLOqIiIg+JPGNjiNpgqQvShpb9rcHDgdua+xreyXwI+CrTYY8E/iSpBHAPKpEdVLZLgCupZQySJoqaRdJm0jaGjgX+L3t50r7sZLeUf4+EfgycFPZ30HS3pKGSxoh6WSq2dObSxxXAVtJOlrSMEmHANvV2i8G5kjaUdJbqEo9Fg/i+RpeHpuAzcq1B/va35LqBrceSROoVtLoHXeP8nxsBrxI9QGi96a8FcCOtXFupSo7OUHSppIOBvYcZAwRETEEtbqUWTuXM0viG53oBWAqcLukF6kS3vvo/2a0c4ADJH2gn/ZrgWeB40oJwvLeDegB1th+uvTdEbiuxHAf1RJqh9fG2hu4t8S1pGynlbYtqWZQnwWeAGYAM0tyju1/Ax+nmmV+DjgVOKjU92L7IuAS4HaqMoGXqZZKG8gNVGUSe1El9qupbkpD0hFlVYv+nES1UsMLVPXUV9TaRpZjz5Z4VgLfLm3zgYmq1iO+2vYrwMHAMaX/oVRLt1Hi2EFSzyAeS0REDClqcWsPNVuuc8qUKV66dOlGDCeiUtbVndLuOGLDKcunPV5fzmyT4aO9+Tb7939SBznpqk+1O4T1ZsSwN8+yzl+ZNK7dIUR0DGmXpu/Fk6fs6ltuX9jS2CM2ndqW9/nM+EZEREREV0jiG9HhSrlCTx9bsxKGiIiI9aCzSh2yjm9Eh7O9iGot4Y5S/sOMiIjoWO29Ua0VSXwjIiIiokWdlfim1CEiIiIiukJmfCMiIiKiJeqwOdQkvhERERHRos4qdUjiGxEREREtEOqwxLez5qcjIiIi1LP+1QAAAcJJREFUIlqUGd+IiIiIeMMEWc4sIiIiIrpFZxUPdFa0EREREREtyoxvRERERLSk025uS+IbERERES0QWc4sIiIiIrpCp93clhrfiIiIiBhyJM2Q9JCkZZJO7aNdks4t7fdI2n2gMZP4RkRERESLNmlxa07SMOA8YCYwEThc0sSGbjOB8WWbC5w/mGgjIiIiIt4wtfgzCHsCy2z/w/YrwOXAQQ19DgIuceU2YJSkdzaN13b/jdLTwKODiS5iPRtn++3tDiI2rvzOiYgYUpq+F0u6DhjT4tgjgDW1/Xm259XGPgSYYfvYsn8kMNX28bU+i4Ezbf+x7N8EnGJ7aX8XbXpzWxKPiNiY8jsnIqJz2J6xAYfva1q4cbZ2MH1eJ6UOERERETHUPA5sX9sfCzzZQp/XSeIbEREREUPNHcB4Se+WNBw4DLimoc81wFFldYdpwHO2n2o2aNbxjYiIiIghxfZaSccD1wPDgIts3y/p06X9AmAJcACwDHgJmDPQuE1vbouIiIiIeLNIqUNEREREdIUkvhERERHRFZL4RkRERERXSOIbEREREV0hiW9EREREdIUkvhERERHRFZL4RkRERERX+A9JrtF27nuhGgAAAABJRU5ErkJggg==\n", + "image/png": "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1apVk3QnuR4yZIj5fsTExGjkyJE6c+aMpkyZYsZ06NBBTZs21fLly1WkSBGdOXNG27dvV0pKivz8/LR9+3Y988wzGjBggAYOHChJ5vfOnj171LNnz7/keQEAAP5qTp08WywWPbfkOx08eSmvuwI4TPqtG7mKDw8P188//6xFixZp0aJFcnNzU4MGDdSpUyeNGDFCXl5eVvGpqXeS88TERL3//vuKjY3VkiVLlC9fvsx9SU9XamqqOed56NCh8vPz02uvvZZtn1JTUzVgwAB5eXnp1VdfzTb2X//6lyIiItS1a1fVrFnTPO/u7q5BgwapdevW8vPz06lTp7Ro0SJ16dJFH374oZm4SVJQUJBmzpypOnXqyMPDQ99//70iIiK0c+dOHTx4UGXKlJEknTlzRpI0duxYNW/eXOvWrdP169c1ffp0tWjRQvv377fqw72GDx+u69eva+LEidk+05kzZzRhwgTVr19fHTt2zDbWUdzd3dW4cWN5e3srJSUl01D3FStWmH9OT09XSEiILBaL3nnnHU2ePFmGYejgwYO6efOm5syZo1q1apnxd885r1evniTJ398/0z0mTpyoEiVK6Ny5cw/iEQEAwEPicR227dTJc/LtNBJnPPZ8fHy0b98+xcbGas+ePYqNjdWXX36p0NBQffDBBzpw4IA5D/jYsWOZqtKhoaEaMmSIzbbHjx+v8ePHm18XKlRIe/fuVWBgYJb9sVgsGjBggPbt26d169apbNmyWcbGxcWpY8eOKlu2rDkvOoOfn1+mRcy6d++uRo0aacKECerfv785VL1v375Wcc2bN1fz5s0VHBysiIgIvfPOO5LuJI3SncRv3bp15gcGwcHBqlixoiIiIvTJJ5/Y7OvkyZP16aefatGiRWYCacvFixfVvn17WSwWrVmz5r6HYTtadHS03nrrLR04cEBXr161ei0hIUElS5ZU7dq1lT9/fg0ePFjDhg1T06ZNVaFCBbvav379ug4cOKCBAwfaXHwOAADA2Tl18ny32EmtVCB/5soZ4GyuXr0qvwW5v65+/frmnOTbt29r/Pjxmj9/viIiIsyFw4KCgrR69WpZLBadPHlSYWFhmjVrlmrWrGlzuO3o0aPVp08f3bp1SzExMZo0aZK6dOmiw4cP25zna7FYNHDgQH3yySeKiopSly5dsuzvyZMn1bx5c7m6umrPnj12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29v1u2fGv/Nl3vqHXDwKOqwP6Tev68wbHR/6338fvawTAAAAAPDgPKb7PJM8A8BfIC0tTdkN9DEMw2pBLwAAADxcWG0bAP4CQUFBcnNzy/Jo2bJlXncRAADAPsYDOJwAlWcA+Ats3rxZt27dyvL1QoUK/YW9AQAAuH8uhuTiyDIsyTMAIMO9q3kDAADAuZA8AwAAAADs9pjuVMWcZwAAAAAAckLlGQAAAABgNyrPAAAAAADAJpJnAAAAAIDdDMNw+HE/Fi9erPLly8vDw0P16tXTvn37so2/deuWJk6cqHLlysnd3V1BQUFavny53fdj2DYAAAAAwKmsWbNGY8aM0eLFi9WkSRN98MEHateunX766ScFBATYvOb555/X+fPnFRkZqYoVKyohIUGpqal235PkGQAAAABgt4dhzvO8efM0YMAADRw4UJK0YMEC7dixQ++//75mzZqVKX779u366quvdOLECRUrVkySFBgYmKt7MmwbAAAAAJDnrl69anXcunXLZlxKSooOHjyoNm3aWJ1v06aNvv32W5vXbNq0SfXr11dERITKlCmjypUra+zYsUpOTra7f1SeAQAAAAB2e1CV57Jly1qdnzp1qqZNm5YpPjExUWlpaSpZsqTV+ZIlS+rcuXM273HixAl9/fXX8vDw0IYNG5SYmKhhw4bp4sWLds97JnkGAAAAAOS506dPy9vb2/za3d092/h7FxqzWCxZLj6Wnp4uwzD06aefqnDhwpLuDP1+7rnn9N5778nT0zPH/pE8AwAAAADs5yIZjpwA/J+2vL29rZLnrPj6+ipfvnyZqswJCQmZqtEZ/Pz8VKZMGTNxlqSqVavKYrHo3//+typVqmRvNwEAAAAAePjlz59f9erV065du6zO79q1S08++aTNa5o0aaKzZ8/qjz/+MM/98ssvcnFxkb+/v133JXkGAAAAANgtY86zI4/ceu2117Rs2TItX75cx48f16uvvqpTp07plVdekSSFhobqxRdfNON79eolHx8fvfTSS/rpp5/0z3/+U+PGjdPLL79s15BtiWHbAAAAAIBccDHuHI5iuY+2evTooaSkJL355puKj49X9erVtXXrVpUrV06SFB8fr1OnTpnxXl5e2rVrl0aOHKn69evLx8dHzz//vMLCwuy+J8kzAAAAAMDpDBs2TMOGDbP52sqVKzOdq1KlSqah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7du3q3DhwubXLi7Wnw/WrVtX3333Xabr3n//fX300Ufq2rWr1flu3brpwIEDCg8PV+XKlbVq1Sq98MILSk9PV69evbJ8vnslJSVp6dKlqlWrlp599lmbfc/OoUOHNGrUKA0cOFD9+vWTq6urChUqlKs2crJq1SodPXpUY8aMcWi7AADg0fO4blVF8gw4uYiICJUvX147duyQq+ufP9I9e/ZURESEVaynp6caN25sft2uXTtVq1ZNUVFRWrhwYaZKbUBAgBnfpEkTVapUSc2aNVNkZKTN5Dk1NVUvvfSShgwZosOHDysxMdFmn+/thy1VqlRRVFSU1bl27dopISFBUVFReu+99+Tu7m71er169eTr65tlm97e3pnua7FY1Lt3b5UrV06tW7c2z2/dulW7du0yE2ZJat68uU6ePKlx48apR48eypfPvkFG5cqV06VLl2QYhhITE3OdPB87dkySNGjQIDVs2DBX1wIAAMAxGLYNOLmkpCT5+vpaJc4Z7q283svV1VW1a9dWSkqKXcOA69evL0k6f/68zdfDw8N18eJFzZw5M+eO36fixYvLxcXF7sQ1J3v37tWJEyf00ksvWb1fGzZskJeXl7p3724V/9JLL+ns2bPav3+/3ffIGBZ+P0JCQtSnTx9JUqNGjWQYhvr37y9J2rVrl7p06SJ/f395eHioYsWKGjJkSKYPLS5cuKDBgwerbNmycnd3V/HixdWkSRPt3r3bvMeWLVt08uRJq2HsGc6ePavnn39ehQoVUuHChdWjRw/FxMTIMAytXLnyvp4LAAA4r4ytqhx5OAMqz87IYpFu38jrXuBBSbmeq/Dg4GAtW7ZMo0aNUu/evVW3bl25ubnZff3vv/+uIkWKqHjx4nbFSlLlypUzvfbTTz8pLCxM69evl5eXV7bt/PbbbypWrJiuXr2qcuXKqWfPnpo0aZI8PT0zxVosFqWlpenatWvauXOnVq5cqddff93mhwU1atRQQkKCfH191bZtW4WFhSkgICDbvkRGRsrFxUUvvfSS1fmjR4+qatWqme5Ts2ZN8/Unn3wy27YdYfHixfrss88UFhamFStWqEqVKub/q99++03BwcEaOHCgChcurLi4OM2bN09PPfWUjhw5Yn4f9O3bVz/88INmzpypypUr6/Lly/rhhx+UlJRk3mPw4MH67bfftGHDBqv7Jycnq1WrVjp79qxmzZqlypUra8uWLerRo8cDf3YAAICHCcmzs7FYpOVtpdP2V73gZG7lbs5HeHi4fv75Zy1atEiLFi2Sm5ubGjRooE6dOmnEiBGZEtmMxbYSExP1/vvvKzY2VkuWLLFZyU1PT1dqaqo553no0KHy8/PTa6+9linu5ZdfVrdu3dS+ffts+/vUU0+pR48eqlKlipKTk7Vt2zZFRETo66+/1t69ezNVy2fPnq3Q0FBJdyq4b7zxhsLCwqxigoKCNHPmTNWpU0ceHh76/vvvFRERoZ07d+rgwYMqU6aMzb5cvnxZ69evV+vWrTMl2UlJSapQoUKma4oVK2a+/leoVq2agoKCJEnVq1c3q/+SrBY4s1gsevLJJxUSEqJy5cpp27Zt6ty5syTpm2++0cCBAzVo0CAzvkuXLlb3KFKkiNzd3TMNa4+KitLx48e1ceNGs702bdooOTlZH374oeMfGAAAPPQcXS2m8owH4/YNEmdY8fHx0b59+xQbG6s9e/YoNjZWX375pUJDQ/XBBx/owIED5jzgY8eOZapKh4aGasiQITbbHj9+vMaPH29+XahQIe3du1eBgYFWcfPmzdOvv/6qTZs25djfexPf9u3bKzAwUGPHjtXGjRszLdrVv39/tWrVShcvXlR0dLTmzJmjK1euaNGiRWZM3759ra5p3ry5mjdvruDgYEVEROidd96x2ZdPP/1UN2/e1MCBA22+nt1Q6/sdhu1ICQkJmjJlirZs2aKzZ88qPT3dfO348eNmstuwYUOtXLlSPj4+atWqlerVq2f36IS9e/eqUKFCZlsZevXqRfIMAAAeKyTPzmzs/0n5C+R1L+BoV69K4aVzfVn9+vXNquTt27c1fvx4zZ8/XxEREebCYUFBQVq9erUsFotOnjypsLAwzZo1SzVr1lTPnj0ztTl69Gj16dNHt27dUkxMjCZNmqQuXbro8OHD8vHxkSSdOnVKU6ZMUXh4uPLnz2/OnU5NTVV6erouX74sd3d3m0OyM/Tp00djx45VTExMpuS5VKlSKlWqlKQ7Fc+iRYtqwoQJevnll1WnTp0s22zYsKEqV66smJiYLGMiIyNVvHhxqypsBh8fH5vV5YsXL0r6swKdV9LT09WmTRudPXtWkydPVo0aNVSwYEGlp6ercePGSk5ONmPXrFmjsLAwc3V1Ly8vde3aVREREeZ7m5WkpCSbK6fndB0AAHh05TPuHI5szxmQPDuz/AWk/AXzuhdwtPxp/3UTbm5umjp1qubPn6+jR4+a5z08PMwEu0GDBmrevLmeeOIJjRkzRh07dsw0xNvf39+Mb9KkiUqVKqU+ffpo6tSpevfddyVJJ06cUHJyskaPHq3Ro0dn6kvRokU1evRou/YQzmmBM0nmatO//PJLtsmzdGcoc1Zt/vjjj/rxxx/1+uuv26zC1qhRQ5999plSU1Ot5j0fOXJEkmzuYf1XOnr0qA4fPqyVK1eqX79+5vn/+7//yxTr6+urBQsWaMGCBTp16pQ2bdqkCRMmKCEhQdu3b8/2Pj4+Pvr+++8znT937tx//xAAAABOhNW2AScXHx9v8/zx48clSaVLZ13F9vHxUXh4uM6fP281DDorvXv3VkhIiD788EOdPHlSklS7dm3t3bs301GrVi0FBgZq7969GjFiRLbtZmxJldP2VdKdYcSSVLFixWzjYmJi9Ouvv2bZZmRkpCRpwIABNl/v2rWr/vjjD61bty5TX0uXLq1GjRrl2NcHKWPY+L3bdX3wwQfZXhcQEKARI0aodevW+uGHH8zz7u7uVtXqDM2bN9e1a9cyDclftWrV/XYdAAA4OVbbBuCU2rZtK39/f3Xq1ElVqlRRenq6Dh06pLffflteXl42q8F3e/HFFzVv3jzNnTtXw4cPl7e3d7bxs2fPVqNGjTRjxgwtW7ZMRYoUUUhISKa4IkWKKDU11eq1ffv2aebMmeratasqVKigmzdvatu2bVq6dKlatGihTp06mbFTp07V+fPn9fTTT6tMmTK6fPmytm/frg8//FDdu3dXvXr1zNhatWqpT58+qlq1qrlg2Jw5c1SqVCn9/e9/z9S3mzdvatWqVXryySdVtWpVm8/Zrl07tW7dWkOHDtXVq1dVsWJFffbZZ9q+fbs++eSTXG+VtW3bNl2/fl3Xrl2TdGd18s8//1zSnXnfBQrcmYIxYMAARUVF6bffflO5cuWybK9KlSoKCgrShAkTZLFYVKxYMW3evFm7du2yirty5YqaN2+uXr16qUqVKipUqJAOHDig7du3q1u3bmZcjRo1tH79er3//vuqV6+eXFxcVL9+fb344ouaP3++XnzxRc2cOVOVKlXS1q1btWPHjkx9+uijjzKtWg4AAPCoIHkGnNykSZO0ceNGzZ8/X/Hx8bp165b8/PzUqlUrhYaGZpkcZnBxcVF4eLg6dOigBQsWaMqUKdnGN2zYUN27d1dUVJRCQ0PNlaDt4efnp3z58mnGjBlKTEyUYRiqVKmS3nzzTb3++utWQ6zr16+vhQsX6osvvlBSUpI8PDxUrVo1zZ8/X0OHDrVqt1q1alq6dKni4+OVkpKi0qVLq2fPnpoyZYr8/Pwy9WP9+vW6dOlSlguF3R03ceJETZkyRRcvXlSVKlX02Wef2ZwfnpOhQ4ea1XpJWrt2rdauXSvpzhZgGYuwpaWlKS0tTRZL9quuu7m5afPmzRo9erSGDBkiV1dXtWrVSrt377ZaOdzDw0ONGjXSxx9/rLi4ON2+fVsBAQEaP3681QcLo0eP1rFjx/TGG2/oypUrslgsslgsKlCggKKjozV69GhNmDBBhmGoTZs2Wr16daatutLT060WLQMAAI8mF8MiFyN3O8Tk1J4zMCw5/YYm6erVqypcuLCuXLmSZVXqRkqqqk25U4n46c22KpCfvPyBSLkuvfWfYbhvnGXO8yPInp83IK/FxcWpfPnyWrFihfr372+ez/j+nbRvgzy8+PvpbjdSnWRMWh6Y1y0yr7vw0Eo+NT2vuwDgEXf16h8qXLieXb97Zvw7Hx6z3qH/zt/847omNO720P/+S4YLAAAAALCb4eB5yg/BDqB2IXkGgPtkzzDlu1fqBgAAeBTk+8/hyPacAattA8B9evPNN+Xm5pbtERcXl9fdfCACAwNlsVishmwDAAA8yiiJAMB9Gjx4sDp27JhtTHZbhQEAADgjR28vxVZVAPCIK126NMkxAADAY4LkGQAAAABgt8d1qyrmPAMAAAAAkAMqzwAAAAAAu+Uz7hyObM8ZUHkGAAAAACAHVJ4BAAAAAHZ7XFfbpvIMAAAAAEAOqDwDAAAAAOxG5RkAAAAAANhE5RkAAAAAYDcXObjy7LimHihn6ScAAAAAAHmGyjMAAAAAwG4uhkX5DItD23MGJM8AAAAAALu5yLFDmJ1lOLSz9BMAAAAAgDxD5RkAAAAAYDe2qgIAAAAAADZReQYAAAAA2I3KMwAAAAAAsInKMwAAAADAbvkMOXSrqnxUngEAAAAAeDRQeQYAAAAA2I05z3AOFscNjwAAAAAA2IfKszOxWKSPu+R1LwAAAAA8xh7XyjPJszO5fUP6d2xe9wIAcnQrzZDSnORfwr/IvG6Red2Fh9Zr6wfkdRcAAMgRyTMAAAAAwG6Pa+WZOc8AAAAAAOSAyjMAAAAAwG4uhmP3ZnaWyjPJMwAAAADAbi6GRS6G43YBcmRbDxLDtgEAAAAAyAGVZwAAAACA3Vzk2Cqss1R0naWfAAAAAADkGSrPAAAAAAC7sVUVAAAAAACwicozAAAAAMBu+Ry8VZUj23qQqDwDAAAAAJADKs8AAAAAALuxzzMAAAAAALCJyjMAAAAAwG6stg0AAAAAAGyi8gwAAAAAsBuVZwAAAAAAYBOVZwAAAACA3Vzk2Cqss1R0SZ4BAAAAAPYzJMORQ60Ztg0AAAAAwKOByjMAAAAAwG6GHFssdpLCM5VnAAAAAAByQuUZAAAAAGA3w8Fznh06f/oBovIMAAAAAEAOqDwDAAAAAOz2uG5V5Sz9BAAAAAAgz1B5BgAAAADYzTAsMgyLQ9tzBlSeAQAAAADIAckz8AjYv3+/unbtqoCAALm7u6tkyZIKDg7W66+/bsaEhITIMAzz8PDwULVq1RQWFqaUlBSr9uLi4qxiXVxcVLRoUbVs2VI7d+7MsT+TJk2SYRiqXr16ptfu7UfG8cwzz1jFnT59Wl27dlWFChVUsGBBFS5cWHXq1NG7776r1NRUq9hp06bZbNPDw8Nm/xITEzV69GgFBgaa71e7du108eJFq7jvv/9ebdu2VaFCheTl5aXmzZvrm2++yfH573Xt2jX9/e9/V5s2bVS8eHEZhqFp06blqo1FixapYsWKyp8/vwzD0OXLl3Pdj+ysWrVKCxYscGibAADg0WQ8gMMZMGwbcHJbtmxR586dFRISooiICPn5+Sk+Pl6xsbFavXq13n77bTO2QoUK+vTTTyVJFy5c0LJlyzR58mSdOnVKS5cuzdT2yJEj1atXL6Wlpennn3/W9OnT1b59e0VHR+vpp5+22Z9Dhw5p7ty5KlmyZJZ9vrsfGYoUKWL19fXr1+Xt7a3JkycrICBAKSkp2rp1q0aOHKlDhw5p2bJlmdrdvn27ChcubH7t4pL588GzZ8+qadOmcnV11eTJk1WpUiUlJiZq7969Vh8iHDhwQE8//bQaNmyojz/+WBaLRREREWrZsqX27t2r4ODgLJ/vXklJSVq6dKlq1aqlZ5991mbfs3Po0CGNGjVKAwcOVL9+/eTq6qpChQrlqo2crFq1SkePHtWYMWMc2i4AAMCjguQZcHIREREqX768duzYIVfXP3+ke/bsqYiICKtYT09PNW7c2Py6Xbt2qlatmqKiorRw4cJMldqAgAAzvkmTJqpUqZKaNWumyMhIm8lzamqqXnrpJQ0ZMkSHDx9WYmKizT7f2w9bqlSpoqioKKtz7dq1U0JCgqKiovTee+/J3d3d6vV69erJ19c323aHDRumW7duKTY2VkWLFjXPd+vWzSpu8uTJKlKkiLZv364CBQpIklq1aqUKFSpo7NixuapAlytXTpcuXZJhGEpMTMx18nzs2DFJ0qBBg9SwYcNcXQsAAOBohhy8z7PjmnqgGLYNOLmkpCT5+vpaJc4ZbFVe7+bq6qratWsrJSXFrmHA9evXlySdP3/e5uvh4eG6ePGiZs6cmXPH71Px4sXl4uKifPny5frauLg4bdq0SYMGDbJKnG355ptvFBISYibOklSoUCE9/fTT+vbbbxUfH2/3fTOGkd+PkJAQ9enTR5LUqFEjGYah/v37S5J27dqlLl26yN/fXx4eHqpYsaKGDBmS6UOLCxcuaPDgwSpbtqzc3d1VvHhxNWnSRLt37zbvsWXLFp08edJq2HuGs2fP6vnnn1ehQoVUuHBh9ejRQzExMTIMQytXrryv5wIAAHA2VJ6dWcqNvO4BHoSU67kKDw4O1rJlyzRq1Cj17t1bdevWlZubm93X//777ypSpIiKFy9uV6wkVa5cOdNrP/30k8LCwrR+/Xp5eXll285vv/2mYsWK6erVqypXrpx69uypSZMmydPTM1OsxWJRWlqarl27pp07d2rlypV6/fXXbX5YUKNGDSUkJMjX11dt27ZVWFiYAgICzNf37dsni8Wi0qVL64UXXtDmzZuVmpqqxo0ba9asWVZDsVNSUjJVtiWZ544cOSI/P79sn9MRFi9erM8++0xhYWFasWKFqlSpYv6/+u233xQcHKyBAweqcOHCiouL07x58/TUU0/pyJEj5vdB37599cMPP2jmzJmqXLmyLl++rB9++EFJSUnmPQYPHqzffvtNGzZssLp/cnKyWrVqpbNnz2rWrFmqXLmytmzZoh49ejzwZwcAAA8nR89TdpbKM8mzM5tbMa97gAfhVu6W6g8PD9fPP/+sRYsWadGiRXJzc1ODBg3UqVMnjRgxIlMim7HYVmJiot5//33FxsZqyZIlNiu56enpSk1NNec8Dx06VH5+fnrttdcyxb388svq1q2b2rdvn21/n3rqKfXo0UNVqlRRcnKytm3bpoiICH399dfau3dvpmr57NmzFRoaKulOBfeNN95QWFiYVUxQUJBmzpypOnXqyMPDQ99//70iIiK0c+dOHTx4UGXKlJEknTlzRpI0duxYNW/eXOvWrdP169c1ffp0tWjRQvv371fNmjUlSdWqVVNMTIzS09PNPqWmpmr//v2SZCaeD1q1atUUFBQkSapevbpZ/ZekV155xfyzxWLRk08+qZCQEJUrV07btm1T586dJd2pog8cOFCDBg0y47t06WJ1jyJFisjd3T3TcPqoqCgdP35cGzduNNtr06aNkpOT9eGHHzr+gQEAwEPPxbhzOLI9Z0DyDDg5Hx8f7du3T7GxsdqzZ49iY2P15ZdfKjQ0VB988IEOHDhgzgM+duxYpqp0aGiohgwZYrPt8ePHa/z48ebXhQoV0t69exUYGGgVN2/ePP3666/atGlTjv29N/Ft3769AgMDNXbsWG3cuFFdu3a1er1///5q1aqVLl68qOjoaM2ZM0dXrlzRokWLzJi+fftaXdO8eXM1b95cwcHBioiI0DvvvCPpTpIvSf7+/lq3bp35gUFwcLAqVqyoiIgIffLJJ5LuLJY2YMAAjRgxQhMnTlR6erqmT5+ukydPSsp5SPxfISEhQVOmTNGWLVt09uxZ8/kk6fjx42ay27BhQ61cuVI+Pj5q1aqV6tWrZ/fohL1796pQoUJmWxl69epF8gwAAB4rJM/Oauz/SfkL5BwH53P1qhReOteX1a9f36xK3r59W+PHj9f8+fMVERFhLhwWFBSk1atXy2Kx6OTJkwoLC9OsWbNUs2ZN9ezZM1Obo0ePVp8+fXTr1i3FxMRo0qRJ6tKliw4fPiwfHx9J0qlTpzRlyhSFh4crf/785tzp1NRUpaen6/Lly3J3d7c5JDtDnz59NHbsWMXExGRKnkuVKqVSpUpJulPxLFq0qCZMmKCXX35ZderUybLNhg0bqnLlyoqJiTHPZfS5VatWVpV2Pz8/1apVSz/88IN57uWXX9aFCxcUFham999/X9KdJHvs2LGaPXu2Wc3OK+np6WrTpo3Onj2ryZMnq0aNGipYsKDS09PVuHFjJScnm7Fr1qxRWFiYubq6l5eXunbtqoiICPO9zUpSUpLNldNzug4AADy6Htdh23lfOsH9yV9Ayl+Q41E9/ktubm6aOnWqJOno0aPmeQ8PD9WvX18NGjTQc889pz179qhkyZIaM2aM/vjjj0zt+Pv7q379+mrSpIlef/11LVu2TGfOnDHblqQTJ04oOTlZo0ePVtGiRc3jm2++0fHjx1W0aFFz2HVO7KnmZqw2/csvv+QYa7FYrNrMGJJtT6x0p/KemJioI0eOKC4uTt9++60uXbqkggULql69ejne/0E6evSoDh8+rDlz5mjkyJEKCQlRgwYNzA8I7ubr66sFCxYoLi5OJ0+e1KxZs7R+/Xpz4bHs+Pj42Fwg7ty5c454DAAAgPu2ePFilS9fXh4eHqpXr5727duXZez69evVunVrFS9eXN7e3goODtaOHTtydT+SZ8DJZbXq8/HjxyVJpUtnXcX28fFReHi4zp8/bzUMOiu9e/dWSEiIPvzwQ3P4cu3atbV3795MR61atRQYGKi9e/dqxIgR2babsSVVTttXSXeGEUtSxYrZz/mPiYnRr7/+atVmo0aN5O/vr507dyotLc08f/bsWR0+fNjm/d3d3VW9enWVK1dOp06d0po1azRo0KBsK+l/hYzVsO9d1OyDDz7I9rqAgACNGDFCrVu3tqq0u7u7W1WrMzRv3lzXrl3LNCR/1apV99t1AADg5AzD8UdurVmzRmPGjNHEiRP1448/qmnTpmrXrp1OnTplM/6f//ynWrdura1bt+rgwYNq3ry5OnXqpB9//NHuezJsG3Bybdu2lb+/vzp16qQqVaooPT1dhw4d0ttvvy0vLy+NHj062+tffPFFzZs3T3PnztXw4cPl7e2dbfzs2bPVqFEjzZgxQ8uWLVORIkUUEhKSKa5IkSJKTU21em3fvn2aOXOmunbtqgoVKujmzZvatm2bli5dqhYtWqhTp05m7NSpU3X+/Hk9/fTTKlOmjC5fvqzt27frww8/VPfu3a0qv7Vq1VKfPn1UtWpVc8GwOXPmqFSpUvr73/9uxrm4uGj+/Pl6/vnn1aVLFw0dOlTXr1/XjBkzlD9/fqsK+dGjR7Vu3TrVr19f7u7uOnz4sMLDw1WpUiXNmDEj2/fIlm3btun69eu6du2apDurk3/++eeS7sz7ztgSa8CAAYqKitJvv/2mcuXKZdlelSpVFBQUpAkTJshisahYsWLavHmzdu3aZRV35coVNW/eXL169VKVKlVUqFAhHThwQNu3b7fa27pGjRpav3693n//fdWrV08uLi6qX7++XnzxRc2fP18vvviiZs6cqUqVKmnr1q02P6n96KOP9NJLL+X6vQEAAMitefPmacCAARo4cKAkacGCBdqxY4fef/99zZo1K1P8ggULrL5+6623tHHjRm3evDnbqYB3I3kGnNykSZO0ceNGzZ8/X/Hx8bp165b8/PzUqlUrhYaGqmrVqtle7+LiovDwcHXo0EELFizQlClTso1v2LChunfvrqioKIWGhporQdvDz89P+fLl04wZM5SYmCjDMFSpUiW9+eabev31162GTdevX18LFy7UF198oaSkJHl4eKhatWqaP3++hg4datVutWrVtHTpUsXHxyslJUWlS5dWz549NWXKlEzbST333HPasGGDZs6cqeeee07u7u5q1qyZ1qxZY/Us+fPnV3R0tBYuXKg//vhDAQEBeuWVVzRhwgQVLJj7ofVDhw41q/WStHbtWq1du1bSnS3AMhZhS0tLU1pamiyW7Fddd3Nz0+bNmzV69GgNGTJErq6uatWqlXbv3m21PZeHh4caNWqkjz/+WHFxcbp9+7YCAgI0fvx4qw8WRo8erWPHjumNN97QlStXZLFYZLFYVKBAAUVHR2v06NGaMGGCDMNQmzZttHr1aj355JNWfUpPT7datAwAADya8nrOc0pKig4ePKgJEyZYnW/Tpo2+/fZbu9pIT0/XtWvXVKxYMbvva1hy+g1N0tWrV1W4cGFduXIly6rUjZRUVZtypxLx05ttVSA/ebnDpVyX3vrPENw3zjpkbiwePvb8vAF5LS4uTuXLl9eKFSus5k5nfP+O+/ILuXvxd9Td5naNzOsuPLReWz8gr7vw0JpZPyDnIAD4L1y9+ocKF65n1++eGf/Of39yjby8Hbd48R9Xb6hhuR46ffq0VR/c3d0zTVGT7ky5K1OmjL755hurD/PfeustRUVF6X//939zvOecOXMUHh6u48ePq0SJEnb1kznPAAAAAAC7GQ/gkKSyZcuqcOHC5mFr+LVVP+6ZLG2xWDKds+Wzzz7TtGnTtGbNGrsTZ4lh2wBw3+wZpuzqyl+zAAAA9rBVebbF19dX+fLly7T7R0JCgs0tNu+2Zs0aDRgwQGvXrlWrVq1y1T8qzwBwn9588025ublle8TFxeV1Nx+IwMBAWSwWu7a7AgAAjxYXw/GHJHl7e1sdWSXP+fPnV7169TItlLpr165Ma7Lc7bPPPlP//v21atUqdejQIdfPTUkEAO7T4MGD1bFjx2xjstsqDAAAAPfntddeU9++fVW/fn0FBwdr6dKlOnXqlF555RVJUmhoqM6cOaOPPvpI0p3E+cUXX9Q777yjxo0bm1VrT09PFS5c2K57kjwDwH0qXbo0yTEAAHjs5PVq25LUo0cPJSUl6c0331R8fLyqV6+urVu3mlt9xsfHW+35/MEHHyg1NVXDhw/X8OHDzfP9+vXTypUr7bonyTMAAAAAwOkMGzZMw4YNs/navQnxl19++V/fj+QZAAAAAGA/wyLDyHHH41y15wxIngEAAAAAdnsYhm3nhQeSPN9ISXsQzSIlTbK4q4BxK697AgAAAACPlQeSPNcP2/0gmoUkaYXiPHrldScAAAAAPKYM487hyPacgcP2efZ0y6f65Yo6qjkAAAAAAB4aDqs8G4ahta8EK/k2Q7YfmJQb0pyKed0LAAAAAI8xFzmwCuvgth4khw7bNgxDBfKzBtmDk09ivjMAAAAA/OXIdAEAAAAAdmPOMwAAAAAAsInKMwAAAADAbo/rPs9UngEAAAAAyAGVZwAAAACA3ZjzDAAAAAAAbKLyDAAAAACwG3OeAQAAAACATVSeAQAAAAB2czHuHI5szxmQPAMAAAAA7MawbQAAAAAAYBOVZwAAAACA3QzDIsOwOLQ9Z0DlGQAAAACAHFB5BgAAAADY7XGd80zyDABwuIW9P5bh4pbX3XiojN0wIK+78NDyyOccw/UAAI83kmcAAAAAgN0M487hyPacAXOeAQAAAADIAZVnAAAAAIDdHtc5z1SeAQAAAADIAZVnAAAAAIDdXOTYKqyzVHSdpZ8AAAAAAOQZKs8AAAAAAPs5eLVtZ5n0TOUZAAAAAIAcUHkGAAAAAOTC47neNskzAAAAAMBuxn/+c2R7zoBh2wAAAAAA5IDKMwAAAADAbobhIsNwXB3WkW09SM7RSwAAAAAA8hCVZwAAAABALjyeC4ZReQYAAAAAIAdUngEAAAAAdrtTd3bkatvOgcozAAAAAAA5oPIMAAAAAMgF5jwDAAAAAAAbqDwDAAAAAOzGPs8AAAAAAMAmKs8AAAAAgFxgzjMAAAAAALCByjMAAAAAwG7Gf/5zZHvOgOQZAAAAAGC3xzV5Ztg2AAAAAAA5oPIMAAAAAMgFFzm2DuscNV3n6CWAbO3fv19du3ZVQECA3N3dVbJkSQUHB+v11183Y0JCQmQYhnl4eHioWrVqCgsLU0pKilV7cXFxVrEuLi4qWrSoWrZsqZ07d+bYn0mTJskwDFWvXj3Ta/f2I+N45plnrOJOnz6trl27qkKFCipYsKAKFy6sOnXq6N1331VqaqpV7LRp02y26eHhYbN/iYmJGj16tAIDA833q127drp48WKm2K+//lrt27dX0aJF5enpqUqVKmnGjBk5vgd3u3btmv7+97+rTZs2Kl68uAzD0LRp03LVxqJFi1SxYkXlz59fhmHo8uXLubo+J6tWrdKCBQsc2iYAAMCjhMoz4OS2bNmizp07KyQkRBEREfLz81N8fLxiY2O1evVqvf3222ZshQoV9Omnn0qSLly4oGXLlmny5Mk6deqUli5dmqntkSNHqlevXkpLS9PPP/+s6dOnq3379oqOjtbTTz9tsz+HDh3S3LlzVbJkySz7fHc/MhQpUsTq6+vXr8vb21uTJ09WQECAUlJStHXrVo0cOVKHDh3SsmXLMrW7fft2FS5c2PzaxSXz54Nnz55V06ZN5erqqsmTJ6tSpUpKTEzU3r17M32IsGrVKvXt21fPP/+8PvroI3l5eem3337T2bNns3w2W5KSkrR06VLVqlVLzz77rM2+Z+fQoUMaNWqUBg4cqH79+snV1VWFChXKVRs5WbVqlY4ePaoxY8Y4tF0AAPDoyShUOLI9Z0DyDDi5iIgIlS9fXjt27JCr658/0j179lRERIRVrKenpxo3bmx+3a5dO1WrVk1RUVFauHBhpkptQECAGd+kSRNVqlRJzZo1U2RkpM3kOTU1VS+99JKGDBmiw4cPKzEx0Waf7+2HLVWqVFFUVJTVuXbt2ikhIUFRUVF677335O7ubvV6vXr15Ovrm227w4YN061btxQbG6uiRYua57t162YVd+bMGQ0ePFhDhgzR4sWLzfPNmzfPtn1bypUrp0uXLskwDCUmJuY6eT527JgkadCgQWrYsGGu7w8AAID/HsO2ASeXlJQkX19fq8Q5g63K691cXV1Vu3ZtpaSk2DUMuH79+pKk8+fP23w9PDxcFy9e1MyZM3Pu+H0qXry4XFxclC9fvlxfGxcXp02bNmnQoEFWibMty5Yt0/Xr1zV+/Pj77arpv/l0NiQkRH369JEkNWrUSIZhqH///pKkXbt2qUuXLvL395eHh4cqVqyoIUOGZPrQ4sKFCxo8eLDKli0rd3d3FS9eXE2aNNHu3bvNe2zZskUnT560Gvae4ezZs3r++edVqFAhFS5cWD169FBMTIwMw9DKlSvv67kAAIAzMx7A8fCj8uysUm7kdQ/woKRcz1V4cHCwli1bplGjRql3796qW7eu3Nzc7L7+999/V5EiRVS8eHG7YiWpcuXK/7+9ew+KqvzjOP5ZFNgMvIAzAqKgqIPmJQdvmJY0BKNJDF7STM3UVCYnUJkEMwmEEdGAtLznpZrSMTVlFNOUii6kiDlhOJmpNAPqaKOQ4QXY3x/E/tpAdjFIN98vZmecs9/znOesLHu++33O89R67scff1RSUpJ27twpFxeXets5c+aM3NzcVFpaKh8fH40fP14LFy7UQw89VCvWZDKpsrJSZWVlOnDggDZv3qx58+bV+WVBr169dOnSJbVt21ahoaFKSkpSx44dzc/n5OTIZDLJy8tLzz33nDIzM1VRUaFBgwZpyZIlCgwMNMd++eWXcnNz06lTpxQeHq6CggK5ublp1KhRSk1NVcuWLa2+Xo1h1apV+uijj5SUlKRNmzbJ39/f/H915swZBQYGavr06WrVqpXOnTuntLQ0DRkyRD/88IP592DSpEnKz89XcnKyunXrpqtXryo/P19XrlwxH2PGjBk6c+aMdu3aZXH88vJyBQcHq7i4WEuWLFG3bt20d+9ejRs37l85fwAAgPsFybO9Wt7lXvcATeWmqUHhKSkpOnXqlFauXKmVK1fK0dFR/fv3V1hYmGbPnl0rka2ZbOvy5ctavXq18vLytGbNmjoruVVVVaqoqDDf8xwZGSlPT0/NnTu3VtzUqVM1atQojRgxot7+DhkyROPGjZO/v7/Ky8uVlZWl1NRUffXVV8rOzq5VLV+6dKni4uIkVVdwFyxYoKSkJIsYPz8/JScnq2/fvjIajTpy5IhSU1N14MABHTt2TO3bt5dUPRRbkmJiYhQUFKQdO3bo+vXrSkhI0JNPPqnvvvtOvXv3Nsf+8ccfGjt2rOLi4pSRkaGjR48qPj5eBQUFysnJ+Vfuz+nRo4f8/PwkST179jRX/yVp1qxZ5n+bTCYNHjxYw4YNk4+Pj7KysvTMM89Ikr7++mtNnz5dL730kjk+PDzc4hitW7eWs7NzreH0W7ZsUWFhoXbv3m1uLyQkROXl5Vq/fn3jnzAAALjvPajrPJM82xPHFlKHQdKvufe6J7iPuLu7KycnR3l5eTp06JDy8vL0+eefKy4uTmvXrtXRo0fN9wGfPHmyVlU6Li5OM2fOrLPt+fPnWwxbdnV1VXZ2tnx9fS3i0tLSdPr0ae3Zs8dqf/+e+I4YMUK+vr6KiYnR7t27FRERYfH8lClTFBwcrN9++02HDx/WsmXLdO3aNa1cudIcM2nSJIt9goKCFBQUpMDAQKWmpuqtt96SVJ3kS5K3t7d27Nhh/sIgMDBQXbp0UWpqqj744ANz7I0bNxQfH6/Y2FhJ1cObnZycFB0drUOHDik4ONjq+TalS5cuadGiRdq7d6+Ki4vN5ydJhYWF5mR3wIAB2rx5s9zd3RUcHKyAgACbRydkZ2fL1dXV3FaNCRMmkDwDAIAHCsmzPTEYpKn7pdsM2f5PKy2VUrwavFu/fv3MVcnbt29r/vz5Sk9PV2pqqnniMD8/P23dulUmk0nnz59XUlKSlixZot69e2v8+PG12oyKitLEiRN18+ZN5ebmauHChQoPD9eJEyfk7u4uSSoqKtKiRYuUkpIiJycn873TFRUVqqqq0tWrV+Xs7FznkOwaEydOVExMjHJzc2slzx4eHvLw8JBUXfFs06aNYmNjNXXqVPXt2/eObQ4YMEDdunVTbu7/v2yq6XNwcLBFpd3T01N9+vRRfn6+Rezp06cVGhpq0e7w4cMVHR2t/Pz8e5o8V1VVKSQkRMXFxXr99dfVq1cvPfzww6qqqtKgQYNUXl5ujt22bZuSkpLMs6u7uLgoIiJCqamp5tf2Tq5cuVLnzOnW9gMAAP9lD+Y6zyTP9sZgkJwevte9QFNyqvzHTTg6Oio+Pl7p6ekqKCgwbzcajeYEu3///goKCtIjjzyi6OhojRw5stYQb29vb3P8Y489Jg8PD02cOFHx8fF6++23JUm//PKLysvLFRUVpaioqFp9adOmjaKiomxaQ9jaBGeSzLNN//TTT/Umz1L1UOa/tlkzJNvW2L8m3n+Ns7WvTamgoEAnTpzQ5s2b9cILL5i3//zzz7Vi27Ztq4yMDGVkZKioqEh79uxRbGysLl26pP3799d7HHd3dx05cqTW9gsXLvzzkwAAALAj9pHiA7ijkpKSOrcXFhZKkry87lzFdnd3V0pKii5evGgxDPpOnn/+eQ0bNkzr16/X+fPnJUmPPvqosrOzaz369OkjX19fZWdna/bs2fW2W7MklbXlq6TqYcSS1KVL/ff95+bm6vTp0xZtDhw4UN7e3jpw4IAqK///JUVxcbFOnDhhETt69GhJUlZWlkW7+/bts7mvTanmfuu/L9e1du3aevfr2LGjZs+eraeeesqi0u7s7GxRra4RFBSksrKyWkPyP/zww7vtOgAAsHOGJvixB1SeATsXGhoqb29vhYWFyd/fX1VVVfr+++/15ptvysXFpc5q8F9NnjxZaWlpWr58uV5++WWrs0gvXbpUAwcO1OLFi7Vhwwa1bt1aw4YNqxXXunVrVVRUWDyXk5Oj5ORkRUREqHPnzrpx44aysrK0bt06PfnkkwoLCzPHxsfH6+LFi3r88cfVvn17Xb16Vfv379f69es1duxYBQQEmGP79OmjiRMnqnv37uYJw5YtWyYPDw+9+uqr5jgHBwelp6fr2WefVXh4uCIjI3X9+nUtXrxYTk5O5onJpOoh4mFhYUpMTDQPhc7Ly1NCQoJGjhypIUOG1Ps6/V1WVpauX7+usrIySdWzk3/88ceSqu/7btGihSRp2rRp2rJli86cOSMfH587tufv7y8/Pz/FxsbKZDLJzc1NmZmZOnjwoEXctWvXFBQUpAkTJsjf31+urq46evSo9u/fb7G2da9evbRz506tXr1aAQEBcnBwUL9+/TR58mSlp6dr8uTJSk5OVteuXbVv3z59+umntfr03nvv6cUXX2zQ6wIAAGAvSJ4BO7dw4ULt3r1b6enpKikp0c2bN+Xp6ang4GDFxcWpe/fu9e7v4OCglJQUPf3008rIyNCiRYvqjR8wYIDGjh2rLVu2KC4uzjwTtC08PT3VrFkzLV68WJcvX5bBYFDXrl2VmJioefPmWQyF7tevn1asWKFPPvlEV65ckdFoVI8ePZSenq7IyEiLdnv06KF169appKREt27dkpeXl8aPH69FixbJ09PTInbMmDHatWuXkpOTNWbMGDk7O+uJJ57Qtm3bap3Ltm3blJCQoHXr1ikhIUFeXl6aM2eO4uPjbT7nGpGRkeZqvSRt375d27dvl1S9BFjNJGyVlZWqrKw0Dw+/E0dHR2VmZioqKkozZ85U8+bNFRwcrM8++8xieS6j0aiBAwfq/fff17lz53T79m117NhR8+fPt/hiISoqSidPntSCBQt07do1mUwmmUwmtWjRQocPH1ZUVJRiY2NlMBgUEhKirVu3avDgwRZ9qqqqspi0DAAA/DcZDIZGXXXk31jBpDEYTNau0CSVlpaqVatWunbt2r+2tinwoOL9Bntw7tw5derUSZs2bdKUKVPM22t+f53bj5bBwfb1xh8EMbum3esu3LeMzRq2RN+D5LVH7zwCBQAaQ2np72rVKsCma8+az/mLv32mli0bbx6m0tLraucWfN9f/3LPMwAAAAAAVjBsGwDuki3DlJs3588sAAD4b6me4qvx6rD2MmEYlWcAuEuJiYlydHSs93Hu3Ll73c0m4evrK5PJZDFkGwAA4L+MkggA3KUZM2Zo5MiR9cbUt1QYAACAfTL8+WjM9u5/JM8AcJe8vLxIjgEAAB4QJM8AAAAAAJs9qEtVcc8zAAAAAABWUHkGAAAAADTAg3nPM5VnAAAAAACsoPIMAAAAALCZQQ6NvM6zfdR07aOXAAAAAADcQ1SeAQAAAAANwD3PAAAAAACgDlSeAQAAAAA2M/z505jt2QOSZwAAAACAzQwGgwyGRkyeG7GtpsSwbQAAAAAArKDyDAAAAABoAAc1bh3WPmq69tFLAAAAAADuISrPAAAAAACbPagThlF5BgAAAADACirPAAAAAIAGMPz5aMz27n9UngEAAAAAsILKMwAAAADAZqzzDAAAAACAnVi1apU6deoko9GogIAA5eTk1Bv/xRdfKCAgQEajUZ07d9aaNWsadDySZwAAAABAAzg0waNhtm3bpujoaL322ms6fvy4hg4dquHDh6uoqKjO+LNnz2rEiBEaOnSojh8/rgULFuiVV17Rjh07GnTWAAAAAADYjbS0NE2bNk3Tp09X9+7dlZGRoQ4dOmj16tV1xq9Zs0YdO3ZURkaGunfvrunTp2vq1Klavny5zcckeQYAAAAA2MzQBD8NcevWLR07dkwhISEW20NCQvTNN9/Uuc+3335bKz40NFR5eXm6ffu2Tce1acIwk8kkSSotLbWpUQB3r+Z9VvO+A+xJze/tz7kZatmy5T3uDWD/uPQC0NTu5tqztPT3Ru7D7xZ9qeHs7CxnZ+da8ZcvX1ZlZaXatWtnsb1du3a6cOFCnce4cOFCnfEVFRW6fPmyPD09rfbTpuS5rKxMktShQwdbwgE0grKyMrVq1epedwNoED4vAACwT7Zcezo5OcnDw0MdOjzR6Md3cXGpdf0QHx+vN9544477/H2WbpPJVO/M3XXF17X9TmxKnr28vPTrr7/K1dXVbqYRB+yVyWRSWVmZvLy87nVXgAbj8wIAAPvSkGtPo9Gos2fP6tatW03Sj79fO9RVdZaktm3bqlmzZrWqzJcuXapVXa7h4eFRZ3zz5s3l7u5uUx9tSp4dHBzk7e1tU4MA/jkqzrBXfF4AAGB/GnLtaTQaZTQam7A31jk5OSkgIEAHDx5URESEefvBgwcVHh5e5z6BgYHKzMy02HbgwAH169dPjo6ONh2XCcMAAAAAAHZl7ty52rBhgzZu3KjCwkLNmTNHRUVFmjVrliQpLi5OkydPNsfPmjVL58+f19y5c1VYWKiNGzfq3XffVUxMjM3HtKnyDAAAAADA/WLcuHG6cuWKEhMTVVJSop49e2rfvn3y8fGRJJWUlFis+dypUyft27dPc+bM0TvvvCMvLy+tWLFCo0ePtvmYBhNT+gIAAAAAUC+GbQMAAAAAYAXJMwAAAAAAVpA8AwAAAABgBckzAAAAAABWkDwDAAAAAGAFyTMAAAAAAFaQPAMAAAAAYAXJMwAAAAAAVpA8AwAAAABgBckzAAAAAABWkDwDAAAAAGAFyTMAAAAAAFb8D+aPbxVfX9jLAAAAAElFTkSuQmCC", "text/plain": [ - "
" + "
" ] }, - "metadata": { - "needs_background": "light" - }, + "metadata": {}, "output_type": "display_data" } ], @@ -308,9 +346,9 @@ ], "metadata": { "kernelspec": { - "display_name": "Python (myenv)", + "display_name": "smash-notebooks", "language": "python", - "name": "myenv" + "name": "smash-notebooks" }, "language_info": { "codemirror_mode": { @@ -322,7 +360,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.6" + "version": "3.11.3" } }, "nbformat": 4, diff --git a/doc/release-notes/releases.md b/doc/release-notes/releases.md index 6a492e1c24..bd7e302c21 100644 --- a/doc/release-notes/releases.md +++ b/doc/release-notes/releases.md @@ -1,7 +1,7 @@ # sourmash release notes Please see the -[github releases page](https://github.com/dib-lab/sourmash/releases) +[github releases page](https://github.com/sourmash-bio/sourmash/releases) for detailed release notes for each version! ```{toctree} diff --git a/doc/release-notes/sourmash-2.0.md b/doc/release-notes/sourmash-2.0.md index f3fd868661..c3b8647dd5 100644 --- a/doc/release-notes/sourmash-2.0.md +++ b/doc/release-notes/sourmash-2.0.md @@ -12,7 +12,7 @@ released version is at http://sourmash.readthedocs.io/en/stable/. Python API, command-line, and signature file formats have all changed substantially.** Please post questions about migrating to sourmash 2.0 in the -[sourmash issue tracker](https://github.com/dib-lab/sourmash/issues/new). +[sourmash issue tracker](https://github.com/sourmash-bio/sourmash/issues/new). ## Major new features since 1.0 diff --git a/doc/release-notes/sourmash-3.0.md b/doc/release-notes/sourmash-3.0.md index f4add5824e..db82b72611 100644 --- a/doc/release-notes/sourmash-3.0.md +++ b/doc/release-notes/sourmash-3.0.md @@ -10,25 +10,25 @@ released version is at http://sourmash.readthedocs.io/en/stable/. Python API, command-line, signature and databases file formats are all the same.** We are releasing 3.0 to indicate the build system and internal implementation changed. Please post questions about migrating to sourmash 3.0 -in the [sourmash issue tracker](https://github.com/dib-lab/sourmash/issues/new). +in the [sourmash issue tracker](https://github.com/sourmash-bio/sourmash/issues/new). ## Highlighted changes since 2.0 -This is a list of substantial new features and functionality since sourmash 2.0. For more details check the [releases page on GitHub](https://github.com/dib-lab/sourmash/releases). +This is a list of substantial new features and functionality since sourmash 2.0. For more details check the [releases page on GitHub](https://github.com/sourmash-bio/sourmash/releases). Features: -- Replacing C++ with Rust ([#424](https://github.com/dib-lab/sourmash/pull/424)) -- Create an `Index` abstract base class ([#556](https://github.com/dib-lab/sourmash/pull/556)) -- Dayhoff and HP encoding for proteins ([#689](https://github.com/dib-lab/sourmash/pull/689)) ([#758](https://github.com/dib-lab/sourmash/pull/758)) -- Add `sourmash signature filter` to do abundance filtering. ([#748](https://github.com/dib-lab/sourmash/pull/748)) -- Parallelized compare function with multiprocessing ([#709](https://github.com/dib-lab/sourmash/pull/709)) -- add compute signatures for 10x bam file ([#713](https://github.com/dib-lab/sourmash/pull/713)) +- Replacing C++ with Rust ([#424](https://github.com/sourmash-bio/sourmash/pull/424)) +- Create an `Index` abstract base class ([#556](https://github.com/sourmash-bio/sourmash/pull/556)) +- Dayhoff and HP encoding for proteins ([#689](https://github.com/sourmash-bio/sourmash/pull/689)) ([#758](https://github.com/sourmash-bio/sourmash/pull/758)) +- Add `sourmash signature filter` to do abundance filtering. ([#748](https://github.com/sourmash-bio/sourmash/pull/748)) +- Parallelized compare function with multiprocessing ([#709](https://github.com/sourmash-bio/sourmash/pull/709)) +- add compute signatures for 10x bam file ([#713](https://github.com/sourmash-bio/sourmash/pull/713)) Improvements: -- improve error handling in `sourmash lca index`. ([#798](https://github.com/dib-lab/sourmash/pull/798)) -- Include more base deps: numpy, scipy and matplotlib ([#770](https://github.com/dib-lab/sourmash/pull/770)) -- use `bam2fasta` package to simplify `sourmash compute` ([#768](https://github.com/dib-lab/sourmash/pull/768)) -- add a `--abundances-from` flag to sourmash signature intersect, to preserve abundances ([#747](https://github.com/dib-lab/sourmash/pull/747)) -- Compare outputs can be saved to an output dir ([#715](https://github.com/dib-lab/sourmash/pull/715)) +- improve error handling in `sourmash lca index`. ([#798](https://github.com/sourmash-bio/sourmash/pull/798)) +- Include more base deps: numpy, scipy and matplotlib ([#770](https://github.com/sourmash-bio/sourmash/pull/770)) +- use `bam2fasta` package to simplify `sourmash compute` ([#768](https://github.com/sourmash-bio/sourmash/pull/768)) +- add a `--abundances-from` flag to sourmash signature intersect, to preserve abundances ([#747](https://github.com/sourmash-bio/sourmash/pull/747)) +- Compare outputs can be saved to an output dir ([#715](https://github.com/sourmash-bio/sourmash/pull/715)) diff --git a/doc/release-notes/sourmash-4.0.md b/doc/release-notes/sourmash-4.0.md index 681233ad16..c8e40d5a19 100644 --- a/doc/release-notes/sourmash-4.0.md +++ b/doc/release-notes/sourmash-4.0.md @@ -12,7 +12,7 @@ Please see [our migration guide](../support.md#migrating-from-sourmash-v3x-to-sourmash-v4x) for guidance on updating to sourmash v4, and post questions about migrating to sourmash 4.0 in the -[sourmash issue tracker](https://github.com/dib-lab/sourmash/issues/new). +[sourmash issue tracker](https://github.com/sourmash-bio/sourmash/issues/new). ## Major changes for 4.0 @@ -23,7 +23,7 @@ release; you should get the same results with v4 as you get with v3, except where command-line parameters need to be adjusted as noted below (see: protein ksize #1277, lca summarize changes #1175, sourmash gather on signatures without abundance #1328). Please -[file an issue](https://github.com/dib-lab/sourmash/issues) if your +[file an issue](https://github.com/sourmash-bio/sourmash/issues) if your results change! ### New or changed behavior diff --git a/doc/release.md b/doc/release.md index 9429711237..a1d6b3693c 100644 --- a/doc/release.md +++ b/doc/release.md @@ -3,28 +3,77 @@ These are adapted from the khmer release docs, originally written by Michael Crusoe. -## Required build environment +## Checklist -The basic build environment needed below can be created as follows: +Here's a checklist to copy/paste into an issue: ``` -conda create -y -n sourmash-rc python=3.8 pip cxx-compiler make twine tox tox-conda "setuptools<60" setuptools_scm + +Release candidate testing: +- [ ] Command line tests pass for a release candidate +- [ ] All eight release candidate wheels are built + +Releasing to PyPI: + +- [ ] RC tag(s)s deleted on github +- [ ] Release tag cut +- [ ] Release notes written +- [ ] All eight release wheels built +- [ ] Release wheels uploaded to pypi +- [ ] tar.gz distribution uploaded to pypi + +After release to PyPI and conda-forge/bioconda packages built: + +- [ ] [PyPI page](https://pypi.org/project/sourmash/) updated +- [ ] Zenodo DOI successfully minted upon new github release - [see search results](https://zenodo.org/search?page=1&size=20&q=sourmash&sort=mostrecent) +- [ ] `pip install sourmash` installs the correct version +- [ ] [conda-forge sourmash-minimal-feedstock](https://github.com/conda-forge/sourmash-minimal-feedstock) has updated `sourmash-minimal` to the correct version +- [ ] `mamba create -n smash-release -y sourmash` installs the correct version ``` -Then activate it with `conda activate sourmash-rc`. +## Creating the build environment with conda -You will also need a Rust installation (see -[Development Environment](developer.md)); be sure to update it to the -latest version with `rustup update`. +You can most easily set up your build environment with conda. Your conda version will need to be at least `v4.9.0`. You can check your conda version with `conda --version` and update with `conda update conda`. +Create the basic build environment: + +``` +mamba create -y -n sourmash-rc python=3.10 pip \ + cxx-compiler make twine tox tox-conda rust +``` + +Then activate it with `conda activate sourmash-rc`. + +## Writing release notes + +Draft release notes can be created with `git log --oneline +v4.6.1..latest`, but should then be edited manually. We suggest +putting PRs in the following categories: + +``` +Major new features: + +Minor new features: + +Bug fixes: + +Cleanup and documentation updates: + +Developer updates: + +Dependabot updates: +``` + +A convenient way to edit release notes is to put them in a [hackmd.io](https://hackmd.io) document and edit/display them there; then, create a "draft release notes for v..." issue and paste the markdown into the release PR. + ## Testing a release 0\. First things first: check if Read the Docs is building properly for `latest`. -The build on [Read the Docs] should be passing, -and also check if the [rendered docs] are updated. +The build for the `latest` branch on [Read the Docs] should be passing, +and also the [rendered docs] should be up to date. [Read the Docs]: https://readthedocs.org/projects/sourmash/builds/ [rendered docs]: https://sourmash.readthedocs.io/en/latest/ @@ -40,20 +89,44 @@ cd sourmash You might want to check [the releases page] for next version number, or you can run `make last-tag` and check the output. ``` -new_version=3.0.0 +new_version=4.X.X rc=rc1 ``` -and then tag the release candidate with the new version number prefixed by the letter 'v': + +Next create a new branch to work on release candidates and the version bump: ``` -git tag -a v${new_version}${rc} -m "${new_version} release candidate ${rc}" -git push --tags origin +git checkout -b release/v${new_version} +``` +and update the version number in `pyproject.toml` and `flake.nix`: +``` +sed -i -e "s|version = .*$|version = \"${new_version}\"|g" pyproject.toml +sed -i -e "s|version = .*$|version = \"${new_version}\";|g" flake.nix +``` + +Commit the changes and push the branch: +``` +git add pyproject.toml flake.nix +git commit -m "${new_version} release branch" +git push -u origin release/v${new_version} +``` +and then open a PR for the new branch by following the link printed by +``` +echo "https://github.com/sourmash-bio/sourmash/pull/new/release/v${new_version}" ``` [the releases page]: https://github.com/sourmash-bio/sourmash/releases +Once the checks for the PR work, let's trigger the automatic wheel building +by creating a tag: + +``` +git tag -a v${new_version}${rc} -m "${new_version} release candidate ${rc}" +git push origin refs/tags/v${new_version}${rc} +``` + 3\. Test the release candidate. Bonus: repeat on macOS: ``` -python -m pip install -U virtualenv wheel tox-setuptools-version +python -m pip install -U pip cd .. python -m venv testenv1 @@ -65,20 +138,20 @@ python -m venv testenv4 cd testenv1 source bin/activate -git clone --depth 1 --branch v${new_version}${rc} https://github.com/sourmash-bio/sourmash.git +git clone --depth 1 --branch release/v${new_version} https://github.com/sourmash-bio/sourmash.git cd sourmash python -m pip install -r requirements.txt -make test +pytest && cargo test # Secondly we test via pip cd ../../testenv2 deactivate source bin/activate -python -m pip install setuptools_scm -python -m pip install -e git+https://github.com/sourmash-bio/sourmash.git@v${new_version}${rc}#egg=sourmash[test] +python -m pip install build +python -m pip install -e git+https://github.com/sourmash-bio/sourmash.git@release/v${new_version}#egg=sourmash[test] cd src/sourmash -make test +pytest && cargo test make dist cp dist/sourmash*tar.gz ../../../testenv3/ @@ -89,60 +162,57 @@ cd ../../../testenv3/ deactivate source bin/activate python -m pip install sourmash*tar.gz -python -m pip install pytest -tar xzf sourmash-${new_version}${rc}.tar.gz -cd sourmash-${new_version}${rc} +tar xzf sourmash-${new_version}.tar.gz +cd sourmash-${new_version} python -m pip install -r requirements.txt -make dist -cp -a ../../sourmash/tests/test-data tests/ ## We don't ship the test data, so let's copy it here -make test +pytest && cargo test ``` -4\. Publish the new release on the testing PyPI server. -You will need to [change your PyPI credentials]. -We will be using `twine` to upload the package to TestPyPI and verify -everything works before sending it to PyPI: -``` -python -m pip install twine -twine upload --repository-url https://test.pypi.org/legacy/ dist/sourmash-${new_version}${rc}.tar.gz -``` -Test the PyPI release in a new virtualenv: -``` -cd ../../testenv4 -deactivate -source bin/activate -# install as much as possible from non-test server! -python -m pip install screed pytest numpy matplotlib scipy bam2fasta deprecation cffi -python -m pip install -i https://test.pypi.org/simple --pre sourmash -sourmash info # should print "sourmash version ${new_version}${rc}" -``` +4\. Do any final testing: + + * check that the binder demo notebook is up to date -[change your PyPI credentials]: https://packaging.python.org/tutorials/packaging-projects/#uploading-the-distribution-archives +5\. Wait for GitHub Actions to finish running on the release candidate tag. -5\. Do any final testing: +Wait for the +[various cibuildwheel actions](https://github.com/sourmash-bio/sourmash/actions) +to finish and upload; the +[latest release](https://github.com/sourmash-bio/sourmash/releases) +should have nine wheel files attached to it. - * check that the binder demo notebook is up to date +6\. Remove release candidate tags + +NOTE: If you delete the rc tag before the rc wheels are done building, they +may get added to the wrong release. + +``` +cd ../../sourmash +git tag -d v${new_version}${rc} +git push --delete origin v${new_version}${rc} +``` ## How to make a final release When you've got a thoroughly tested release candidate, cut a release like so: -1\. Create the final tag. Write the changes from previous version in the tag commit message. `git log --oneline` can be useful here, because it can be used to compare the two versions (and hopefully we used descriptive PR names and commit messages). An example comparing `2.2.0` to `2.1.0`: -`git log --oneline v2.1.0..v2.2.0` +1\. Merge the pull request bumping the version. Once the PR is merged, +change back to the `latest` branch and pull the new commit: ``` -cd ../sourmash -git tag -a v${new_version} +git checkout latest +git pull --rebase ``` -2\. Delete the release candidate tag and push the tag updates to GitHub: +2\. Create the final tag and push to GitHub: + ``` -git tag -d v${new_version}${rc} +git tag -a v${new_version} -m "${new_version} release tag" git push --tags origin -git push --delete origin v${new_version}${rc} ``` +(make sure to be in the `latest` branch when creating the final tag!) + 3\. Upload wheels from GitHub Releases to PyPI [GitHub Actions will automatically build wheels and upload them to GitHub Releases](https://github.com/sourmash-bio/sourmash/actions?query=workflow%3Acibuildwheel). @@ -150,14 +220,14 @@ This will take about 45 minutes, or more. After they're built, they must be copied over to PyPI manually. You can do this in two ways: you can manually download all the files -from [the releases page], or, if you have -[`hub`](https://hub.github.com/), you can use that to download the +from [the releases page], or, if you have the +[`GitHub CLI`](https://cli.github.com/), you can use that to download the packages. -Download the wheels with hub: +Download the wheels with the `GitHub CLI`: ``` mkdir -p wheel && cd wheel -hub release download v${new_version} +gh release download v${new_version} ``` or download them manually. @@ -174,7 +244,7 @@ make dist twine upload dist/sourmash-${new_version}.tar.gz ``` -(This should be done *after* the wheels are available, because some of +(This must be done *after* the wheels are available, because some of the conda package build steps require the source dist and are automatically triggered when a new version shows up on PyPI.) @@ -200,7 +270,9 @@ merge it and wait for the `sourmash-minimal` package to show up in conda-forge: conda search sourmash-minimal={new_version} ``` -An example PR for [`3.4.0`](https://github.com/conda-forge/sourmash-minimal-feedstock/pull/7). +[An example conda-forge PR for `4.6.0`](https://github.com/conda-forge/sourmash-minimal-feedstock/pull/37). + +[An example bioconda PR for `4.6.0`](https://github.com/bioconda/bioconda-recipes/pull/38205). ## Bioconda @@ -231,3 +303,7 @@ Examples: - [2.1.0](https://twitter.com/luizirber/status/1166910335120314369) - [2.0.1](https://twitter.com/luizirber/status/1136786447518711808) - [2.0.0](https://twitter.com/luizirber/status/1108846466502520832) + +## After release + +Update version to next minor version + `-dev`, e.g. [this PR](https://github.com/sourmash-bio/sourmash/pull/2584). diff --git a/doc/sourmash-collections.ipynb b/doc/sourmash-collections.ipynb index fe1c15f400..5e5cf1dfeb 100644 --- a/doc/sourmash-collections.ipynb +++ b/doc/sourmash-collections.ipynb @@ -9,7 +9,7 @@ "### Running this notebook.\n", "\n", "You can run this notebook interactively via mybinder; click on this button:\n", - "[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/dib-lab/sourmash/latest?filepath=doc%2Fsourmash-collections.ipynb)\n", + "[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/dib-lab/sourmash/latest?labpath=doc%2Fsourmash-collections.ipynb)\n", "\n", "A rendered version of this notebook is available at [sourmash.readthedocs.io](https://sourmash.readthedocs.io) under \"Tutorials and notebooks\".\n", "\n", diff --git a/doc/sourmash-examples.ipynb b/doc/sourmash-examples.ipynb index c3fc51edfd..dd2d74690e 100644 --- a/doc/sourmash-examples.ipynb +++ b/doc/sourmash-examples.ipynb @@ -17,7 +17,7 @@ "### Running this notebook.\n", "\n", "You can run this notebook interactively via mybinder; click on this button:\n", - "[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/dib-lab/sourmash/latest?filepath=doc%2Fsourmash-examples.ipynb)\n", + "[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/dib-lab/sourmash/latest?labpath=doc%2Fsourmash-examples.ipynb)\n", "\n", "A rendered version of this notebook is available at [sourmash.readthedocs.io](https://sourmash.readthedocs.io) under \"Tutorials and notebooks\".\n", "\n", @@ -510,9 +510,9 @@ ], "metadata": { "kernelspec": { - "display_name": "Python (myenv)", + "display_name": "smash-notebooks", "language": "python", - "name": "myenv" + "name": "smash-notebooks" }, "language_info": { "codemirror_mode": { diff --git a/doc/sourmash-sketch.md b/doc/sourmash-sketch.md index 42a163d335..ecc2dcde0d 100644 --- a/doc/sourmash-sketch.md +++ b/doc/sourmash-sketch.md @@ -52,9 +52,41 @@ By default, `sketch dna` ignores bad k-mers (e.g. non-ACGT characters in DNA). If `--check-sequence` is provided, `sketch dna` will error exit on the first bad k-mer. +### Building a combined sketch from two or more files + +If you have multiple files, sourmash will by default create one sketch +for _each_ file. For situations such as paired-end read files from +Illumina sequencing, you may instead want to build a combined sketch. + +You can build a combined sketch in two ways. + +First, you can use `--name/--merge` to build +a single (named) sketch out of multiple input files: +``` +sourmash sketch dna -p k=31 sample_R1.fq.gz sample_R2.fq.gz \ + --name "sample" -o sample.zip +``` +Here you need to specify a name because sourmash does not pick a default +name when given multiple files; you also need to provide an output file +name because sourmash doesn't pick a default output name in this situation. + +Second, you can stream the input files into `sourmash sketch` via stdin: +``` +gunzip -c sample_R?.fq.gz | sourmash sketch dna -p k=31 - \ + -o sample.zip +``` +As above, you need to specify an output filename because sourmash +can't guess a good default for streaming input. The `--name` option +can still be specified if you want to name the output sketch something +other than `-`. + +Note that the order of sequences or sequence files does not affect +the output of `sourmash sketch` at all: you do not need to +interleave reads or provide the input files in a consistent order. + ### Protein sketches for genomes and proteomes -Likewise, +The command: ``` sourmash sketch translate genome.fna ``` @@ -226,12 +258,25 @@ and then `sourmash sig rename`. ### Locations for output files -Signature files can contain multiple signatures and sketches. Use `sourmash sig describe` to get details on the contents of a file. +Signature files can contain multiple signatures and sketches. Use +`sourmash sig fileinfo` to summarize the contents of a signature file, +and `sourmash sig describe` to get details on the contents of a file. You can use `-o ` to specify a file output location for all the output signatures; `-o -` means stdout. This does not merge signatures unless `--merge` is provided. Specify `--outdir` to put all the signatures in a specific directory. +### Output file formats + +Sourmash can read and write signatures in many different formats, and +`sourmash sketch ... -o ` supports all of the standard +output formats. Our recommendation is to output to zip files - +e.g. `filename.zip` - as this is the smallest and most flexible +signature storage format. + +Please see +[Choosing signature output formats](command-line.md#choosing-signature-output-formats) for more details. + ### Downsampling and flattening signatures Creating signatures is probably the most time consuming part of using sourmash, and it is the only part that requires access to the raw data. Moreover, the output signatures are generally much smaller than the input data. So, we generally suggest creating a large set of signatures once. diff --git a/doc/tutorial-lemonade.md b/doc/tutorial-lemonade.md new file mode 100644 index 0000000000..ed74a83c4f --- /dev/null +++ b/doc/tutorial-lemonade.md @@ -0,0 +1,470 @@ +# Analyzing the genomic and taxonomic composition of an environmental genome using GTDB and sample-specific MAGs with sourmash + +C. Titus Brown, Taylor Reiter, and Tessa Pierce Ward + +July 2022 + +Based on a tutorial developed for MBL STAMPS 2022. + +You'll need 5 GB of disk space and 5 GB of RAM in order to run this tutorial. +It will take about 30 minutes of compute time to execute all the commands. + +--- + +```{contents} + :depth: 2 +``` + +In this tutorial, we'll use sourmash to analyze the composition of a metagenome, both genomically and taxonomically. We'll also use sourmash to classify some MAGs and integrate them into our analysis. + +## Install sourmash + +First, we need to install the software! We'll use conda/mamba to do this. + +The below command installs [sourmash](http://sourmash.readthedocs.io/) and [GNU parallel](https://www.gnu.org/software/parallel/). + +Run: +``` +# create a new environment +mamba create -n smash -y -c conda-forge -c bioconda sourmash parallel +``` +to install the software, and then run + +``` +conda activate smash +``` +to activate the conda environment so you can run the software. + +```{note} +Victory conditions: your prompt should start with +`(smash) ` +and you should now be able to run `sourmash` and have it output usage information!! +``` + +## Create a working subdirectory + +Make a directory named `kmers` and change into it. + +``` +mkdir ~/kmers +cd ~/kmers +``` + +## Download a database and a taxonomy spreadsheet. + +We're going to start by doing a reference-based _compositional analysis_ of the lemonade metagenome from [Taylor Reiter's STAMPS 2022 tutorial on assembly and binning](https://github.com/mblstamps/stamps2022/blob/main/assembly_and_binning/tutorial_assembly_and_binning.md). + +For this purpose, we're going to need a database of known genomes. We'll use the GTDB genomic representatives database, containing ~65,000 genomes - that's because it's smaller than the full GTDB database (~320,000) or Genbank (~1.3m), and hence faster. But you can download and use those on your own, if you like! + +You can find the link to a prepared GTDB RS207 database for k=31 on the [the sourmash prepared databases page](https://sourmash.readthedocs.io/en/latest/databases.html). Let's download it to the current directory: + +``` +curl -JLO https://osf.io/3a6gn/download +``` + +This will create a 1.7 GB file: +``` +ls -lh gtdb-rs207.genomic-reps.dna.k31.zip +``` +and you can examine the contents with sourmash `sig summarize`: +``` +sourmash sig summarize gtdb-rs207.genomic-reps.dna.k31.zip +``` + +which will show you: +``` +>path filetype: ZipFileLinearIndex +>location: /home/stamps2022/kmers/gtdb-rs207.genomic-reps.dna.k31.zip +>is database? yes +>has manifest? yes +>num signatures: 65703 +>** examining manifest... +>total hashes: 212454591 +>summary of sketches: +> 65703 sketches with DNA, k=31, scaled=1000, abund 212454591 total hashes +``` + +There's a lot of things to digest in this output but the two main ones are: +* there are 65,703 genome sketches in this database, for a k-mer size of 31 +* this database represents 212 *billion* k-mers (multiply number of hashes by the scaled number) + +If you want to read more about what, exactly, sourmash is doing, please see [Lightweight compositional analysis of metagenomes with FracMinHash and minimum metagenome covers](https://www.biorxiv.org/content/10.1101/2022.01.11.475838v2), Irber et al., 2022. + +We also want to download the accompanying taxonomy spreadsheet: +``` +curl -JLO https://osf.io/v3zmg/download +``` + +and uncompress it: +``` +gunzip gtdb-rs207.taxonomy.csv.gz +``` + +This spreadsheet contains information connecting Genbank genome identifiers to the GTDB taxonomy - take a look: +``` +head -2 gtdb-rs207.taxonomy.csv +``` +will show you: +``` +>ident,superkingdom,phylum,class,order,family,genus,species +>GCF_000566285.1,d__Bacteria,p__Proteobacteria,c__Gammaproteobacteria,o__Enterobacterales,f__Enterobacteriaceae,g__Escherichia,s__Escherichia coli +``` + +Let's index the taxonomy database using SQLite, for faster access later on: +``` +sourmash tax prepare -t gtdb-rs207.taxonomy.csv \ + -o gtdb-rs207.taxonomy.sqldb -F sql +``` +This creates a file `gtdb-rs207.taxonomy.sqldb` that contains all the information in the CSV file, but which is faster to load than the CSV file. + +## Download and prepare sample reads + +Next, let's download one of the metagenomes from [the assembly and binning tutorial](https://github.com/mblstamps/stamps2022/blob/main/assembly_and_binning/tutorial_assembly_and_binning.md#retrieving-the-data). + +We'll use sample SRR8859675 for today, and you can view sample info [here](https://www.ebi.ac.uk/ena/browser/view/SRR8859675?show=reads) on the ENA. + +To download the metagenome from the ENA, run: +``` +wget ftp://ftp.sra.ebi.ac.uk/vol1/fastq/SRR885/005/SRR8859675/SRR8859675_1.fastq.gz +wget ftp://ftp.sra.ebi.ac.uk/vol1/fastq/SRR885/005/SRR8859675/SRR8859675_2.fastq.gz +``` + +Now we're going to prepare the metagenome for use with sourmash by converting it into a _signature file_ containing _sketches_ of the k-mers in the metagenome. This is the step that "shreds" all of the reads into k-mers of size 31, and then does further data reduction by [sketching](https://en.wikipedia.org/wiki/Streaming_algorithm) the resulting k-mers. + +To build a signature file, we run `sourmash sketch dna` like so: +``` +sourmash sketch dna -p k=31,abund SRR8859675*.gz \ + -o SRR8859675.sig.gz --name SRR8859675 +``` +Here we're telling sourmash to sketch at k=31, and to track k-mer multiplicity (with 'abund'). We sketch _both_ metagenome files together into a single signature named `SRR8859675` and stored in the file `SRR8859675.sig.gz`. + +When we run this, we should see: + +>`calculated 1 signature for 3452142 sequences taken from 2 files` + +which tells you how many reads there are in these two files! + +If you look at the resulting files, +``` +ls -lh SRR8859675* +``` +you'll see that the signature file is _much_ smaller (2.5mb) than the metagenome files (~600mb). This is because of the way sourmash uses a reduced representation of the data, and it's what makes sourmash fast. Please see the paper above for more info! + +Also note that the GTDB prepared database we downloaded above was built using the same `sourmash sketch dna` command, but applied to 65,000 genomes and stored in a zip file. + +## Find matching genomes with `sourmash gather` + +At last, we have the ingredients we need to analyze the metagenome against GTDB! +* the software is installed +* the GTDB database is downloaded +* the metagenome is downloaded and sketched + +Now, we'll run the [sourmash gather](https://sourmash.readthedocs.io/en/latest/command-line.html#sourmash-gather-find-metagenome-members) command to find matching genomes. + +Run gather - this will take ~6 minutes: +``` +sourmash gather SRR8859675.sig.gz gtdb-rs207.genomic-reps.dna.k31.zip --save-matches matches.zip +``` +Here we are saving the matching genome sketches to `matches.zip` so we can rerun the analysis if we like. + +The results will look like this: +``` +overlap p_query p_match avg_abund +--------- ------- ------- --------- +2.0 Mbp 0.4% 31.8% 1.3 GCF_004138165.1 Candidatus Chloroploc... +1.9 Mbp 0.5% 66.9% 2.1 GCF_900101955.1 Desulfuromonas thioph... +0.6 Mbp 0.3% 23.3% 3.2 GCA_016938795.1 Chromatiaceae bacteri... +0.6 Mbp 0.5% 27.3% 6.6 GCA_016931495.1 Chlorobiaceae bacteri... +... +found 22 matches total; +the recovered matches hit 5.3% of the abundance-weighted query +``` +In this output: +* the last column is the name of the matching GTDB genome +* the first column is the estimated overlap between the metagenome and that genome, in base pairs (estimated from shared k-mers) +* the second column, `p_query` is the percentage of metagenome k-mers (weighted by multiplicity) that match to the genome; this will approximate the percentage of _metagenome reads_ that will map to this genome, if you map. +* the third column, `p_match`, is the percentage of the genome k-mers that are matched by the metagenome; this will approximate the percentage of _genome bases_ that will be covered by mapped reads; +* the fourth column is the estimated mean abundance of this genome in the metagenome. + +The other interesting number is here: +>`the recovered matches hit 5.3% of the abundance-weighted query` + +which tells you that you should expect about 5.3% of the metagenome reads to map to these 22 reference genomes. + +```{note} +You can try running gather without abundance weighting: + +`sourmash gather SRR8859675.sig.gz matches.zip --ignore-abundance` + +How does the output differ? + +The main number that changes bigly is: +>`the recovered matches hit 2.4% of the query (unweighted)` + +which represents the proportion of _unique_ kmers in the metagenome that are not found in any genome. + +This is (approximately) the following number: +* suppose you assembled the entire metagenome perfectly into perfect contigs (**note, this is impossible, although you can get close with "unitigs"**); +* and then matched all the genomes to the contigs; +* approximately 2.4% of the bases in the contigs would have genomes that match to them. + +Interestingly, this is the _only_ number in this entire tutorial that is essentially impossible to estimate any way other than with k-mers. + +This number is also a big underestimate of the "true" number for the metagenome - we'll explain more later :) +``` + +## Build a taxonomic summary of the metagenome + +We can use these matching genomes to build a taxonomic summary of the metagenome using [sourmash tax metagenome](https://sourmash.readthedocs.io/en/latest/command-line.html#sourmash-tax-subcommands-for-integrating-taxonomic-information-into-gather-results) like so: + +``` +# rerun gather, save the results to a CSV +sourmash gather SRR8859675.sig.gz matches.zip -o SRR8859675.x.gtdb.csv + +# use tax metagenome to classify the metagenome +sourmash tax metagenome -g SRR8859675.x.gtdb.csv \ + -t gtdb-rs207.taxonomy.sqldb -F human -r order +``` +this shows you the rank, taxonomic lineage, and weighted fraction of the metagenome at the 'order' rank. + +At the bottom, we have a script to plot the resulting taxonomy using [metacoder](https://grunwaldlab.github.io/metacoder_documentation/) - here's what it looks like: + +![metacoder output](https://raw.githubusercontent.com/mblstamps/stamps2022/main/kmers_and_sourmash/metacoder_gather.png) + +## Interlude: why reference-based analyses are problematic for environmental metagenomes + +Reference-based metagenome classification is highly dependent on the organisms present in our reference databases. +For well-studied environments, such as human-associated microbiomes, your classification percentage is likely to be quite high. +In contrast, this is an environmental metagenome, and you can see that we're estimating only 5.3% of it will map to GTDB reference genomes! + +Wow, that's **terrible**! Our taxonomic and/or functional analysis will be based on only 1/20th of the data! + +What could we do to improve that?? There are two basic options - + +(1) Use a more complete reference database, like the entire GTDB, or Genbank. This will only get you so far, unfortunately. (See exercises at end.) +(2) Assemble and bin the metagenome to produce new reference genomes! + +There are other things you could think about doing here, too, but these are probably the "easiest" options. And what's super cool is that we did the second one as part of [Taylor Reiter's STAMPS 2022 tutorial on assembly and binning](https://github.com/mblstamps/stamps2022/blob/main/assembly_and_binning/tutorial_assembly_and_binning.md). So can we include that in the analysis?? + +Yes, yes we can! We can integrate the three MAGs that Taylor generated during her tutorial into the sourmash analysis. + +We'll need to: + +* download the three genomes; +* sketch them with k=31; +* re-run sourmash gather with both GTDB _and_ the MAGs. + +Let's do it!! + +## Update gather with information from MAGs + +First, download the MAGs: + +``` +# Download 3 MAGs generated by ATLAS +curl -JLO https://osf.io/fejps/download +curl -JLO https://osf.io/jf65t/download +curl -JLO https://osf.io/2a4nk/download +``` +This will produce three files, `MAG*.fasta`. + +Now sketch them: +``` +sourmash sketch dna MAG*.fasta --name-from-first +``` +here, `--name-from-first` is a convenient way to give them distinguishing names based on the name of the first contig in the FASTA file; you can see the names of the signatures by doing: +``` +sourmash sig describe MAG1.fasta.sig +``` + +Now, let's re-do the metagenome classification with the MAGs: +``` +sourmash gather SRR8859675.sig.gz MAG*.sig matches.zip -o SRR8859675.x.gtdb+MAGS.csv +``` + +and look, we classify a lot more! +``` +overlap p_query p_match avg_abund +--------- ------- ------- --------- +2.3 Mbp 12.1% 99.9% 39.4 MAG2_1 +2.2 Mbp 26.5% 99.9% 92.4 MAG3_1 +2.0 Mbp 0.4% 31.8% 1.3 GCF_004138165.1 Candidatus Chloroploc... +1.9 Mbp 0.5% 66.9% 2.1 GCF_900101955.1 Desulfuromonas thioph... +1.0 Mbp 2.7% 100.0% 20.3 MAG1_1 +0.6 Mbp 0.3% 23.2% 3.1 GCA_016938795.1 Chromatiaceae bacteri... +0.6 Mbp 0.1% 24.5% 2.1 GCA_016931495.1 Chlorobiaceae bacteri... +... +found 24 matches total; +the recovered matches hit 43.5% of the abundance-weighted query +``` + +Here we see a few interesting things - + +(1) The three MAG matches are all ~100% present in the metagenome. +(2) They are all at high abundance in the metagenome, because assembly needs genomes to be ~5x or more in abundance in order to work! +(3) Because they're at high abundance and 100% present, they account for _a lot_ of the metagenome! + +What's the remaining 50%? There are several answers - + +(1) most of the constitutent genomes aren't in the reference database; +(2) not everything in the metagenome is high enough coverage to bin into MAGs; +(3) not everything in the metagenome is bacterial or archaeal, and we didn't do viral or eukaryotic binning; +(4) some of what's in the metagenome k-mers may simply be erroneous (although with abundance weighting, this is likely to be a small chunk of things) + +## Classify the taxonomy of the MAGs; update metagenome classification + +Now we can also classify the genomes and update the taxonomic summary of the metagenome! + +First, classify the genomes using GTDB; this will use trace overlaps between contigs in the MAGs and GTDB genomes to tentatively identify the _entire_ bin. +``` +for i in MAG*.fasta.sig +do + # get 'MAG' prefix. => NAME + NAME=$(basename $i .fasta.sig) + # search against GTDB + echo sourmash gather $i gtdb-rs207.genomic-reps.dna.k31.zip \ + --threshold-bp=5000 \ + -o ${NAME}.x.gtdb.csv +done | parallel +``` +(This will take about a minute.) + +Here, we're using a for loop and [GNU parallel](https://www.gnu.org/software/parallel/) to classify the three genomes in parallel. + +If you scan the results quickly, you'll see that one MAG has matches in genus Prosthecochloris, another MAG has matches to Chlorobaculum, and one has matches to Candidatus Moranbacteria. + +Let's classify them "officially" using sourmash and an average nucleotide identity threshold of 0.8 - +``` +sourmash tax genome -g MAG*.x.gtdb.csv \ + -t gtdb-rs207.taxonomy.sqldb -F human \ + --ani 0.8 +``` +This is an extremely liberal ANI threshold, incidentally; in reality you'd probably want to do something more stringent, as at least one of these is probably a new species. + +You should see: +``` +>sample name proportion lineage +>----------- ---------- ------- +>MAG3_1 5.3% d__Bacteria;p__Bacteroidota;c__Chlorobia;o__Chlorobiales;f__Chlorobiaceae;g__Prosthecochloris;s__Prosthecochloris vibrioformis +>MAG2_1 5.0% d__Bacteria;p__Bacteroidota;c__Chlorobia;o__Chlorobiales;f__Chlorobiaceae;g__Chlorobaculum;s__Chlorobaculum parvum_B +>MAG1_1 1.1% d__Bacteria;p__Patescibacteria;c__Paceibacteria;o__Moranbacterales;f__UBA1568;g__JAAXTX01;s__JAAXTX01 sp013334245 +``` +The proportion here is the fraction of k-mers in the MAG that are annotated. + +Now let's turn this into a lineage spreadsheet: +``` +sourmash tax genome -g MAG*.x.gtdb.csv \ + -t gtdb-rs207.taxonomy.sqldb -F lineage_csv \ + --ani 0.8 -o MAGs +``` +This will produce a file `MAGs.lineage.csv`; let's take a look: +``` +cat MAGs.lineage.csv +``` +You should see: +``` +>ident,superkingdom,phylum,class,order,family,genus,species +>MAG1_1,d__Bacteria,p__Patescibacteria,c__Paceibacteria,o__Moranbacterales,f__UBA1 +568,g__JAAXTX01,s__JAAXTX01 sp013334245 +>MAG2_1,d__Bacteria,p__Bacteroidota,c__Chlorobia,o__Chlorobiales,f__Chlorobiaceae, +g__Chlorobaculum,s__Chlorobaculum parvum_B +>MAG3_1,d__Bacteria,p__Bacteroidota,c__Chlorobia,o__Chlorobiales,f__Chlorobiaceae, +g__Prosthecochloris,s__Prosthecochloris vibrioformis +``` + +And if we re-classify the metagenome using the combined information, we see: +``` +sourmash tax metagenome -g SRR8859675.x.gtdb+MAGS.csv \ + -t gtdb-rs207.taxonomy.sqldb MAGs.lineage.csv \ + -F human -r order +``` +Now only 56.5% remains unclassified, which is much better than before! + +## Interlude: where we are and what we've done so far + +To recap, we've done the following: +* analyzed a metagenome's composition against 65,000 GTDB genomes, using 31-mers; +* found that a disappointingly small fraction of the metagenome can be identified this way. +* incorporated MAGs built from the metagenome into this analysis, bumping up the classification rate to ~45%; +* added taxonomic output to both sets of analyses. + +ATLAS only bins bacterial and archaeal genomes, so we wouldn't expect much in the way of viral or eukaryotic genomes to be binned. + +But... how much even _assembles_? + +Let's pick a few of the matching genomes out from GTDB and evaluate how many of the k-mers from that genome match to the unassembled metagenome, and then how many of them match to the assembled contigs. + +First, download the contigs: +``` +curl -JLO https://osf.io/jfuhy/download +``` +this produces a file `SRR8859675_contigs.fasta`. + +Sketch the contigs into a sourmash signature - +``` +sourmash sketch dna SRR8859675_contigs.fasta --name-from-first +``` + +Now, extract one of the top gather matches to use as a query; this is "Chromatiaceae bacterium": +``` +sourmash sig cat matches.zip --include GCA_016938795.1 -o GCA_016938795.sig +``` + +### Evaluate containment of known genomes in reads vs assembly + +```{note} +If you want to just start here, you can download the files needed for the below sourmash searches from [this link](https://github.com/mblstamps/stamps2022/raw/main/kmers_and_sourmash/assembly-loss-files.zip). +``` + +Now do a containment search of this genome against both the unassembled metagenome and the assembled (but unbinned) contigs - +``` +sourmash search --containment GCA_016938795.sig \ + SRR8859675*.sig* --threshold=0 --ignore-abund +``` +We see: +``` +similarity match +---------- ----- + 23.3% SRR8859675 + 4.7% SRR8859675_0 +``` +where the first match (at 23.3% containment) is to the metagenome. (You'll note this matches the % in the gather output, too.) + +The second match is to the assembled contigs, and it's 4.7%. That means ~19% of the k-mers that match to this GTDB genome are present in the unassembled metagenome, but are lost during the assembly process. + +Why? + +Some thoughts and answers + +It _could_ be that the GTDB genome is full of errors, and those errors are shared with the metagenome, and assembly is squashing those errors. Yay! + +But this is extremely unlikely... This GTDB genome was built and validated entirely independently from this sample... + +It's much more likely (IMO) that one of two things is happening: + +(1) this sample is at low abundance in the metagenome, and assembly can only recover parts of it. +(2) this sample contains _several_ strain variants of this genome, and assembly is squashing the strain variation, because that's what assembly does. + +Note, you can try the above with another one of the top gather matches and you'll see it's *entirely* lost in the process of assembly - +``` +sourmash sig cat matches.zip --include GCF_004138165.1 -o GCF_004138165.sig +sourmash search --containment GCF_004138165.sig \ + SRR8859675*.sig* \ + --ignore-abund --threshold=0 +``` + +## Summary and concluding thoughts + +Above, we demonstrated a _reference-based_ analysis of shotgun metagenome data using sourmash. + +We then _updated our references_ using the MAGs produced from assembly and binning tutorial, which increased our classification rate substantially. + +Last but not least, we looked at the loss of k-mer information due to metagenome assembly. + +All of these results were based on 31-mer overlap and containment - k-mers FTW! + +A few points: + +* We would have gotten slightly different results using k=21 or k=51; more of the metagenome would have been classified with k=21, while the classification results would have been more closely specific to genomes with k=51; +* sourmash is a nice one-stop-shop tool for doing this, but you could have gotten similar results by using other tools. +* Next steps here could include mapping reads to the genomes we found, and/or doing functional analysis on the matching genes and genomes. diff --git a/doc/tutorial-lin-taxonomy.md b/doc/tutorial-lin-taxonomy.md new file mode 100644 index 0000000000..5512a6115f --- /dev/null +++ b/doc/tutorial-lin-taxonomy.md @@ -0,0 +1,528 @@ +# Analyzing Metagenome Composition using the LIN taxonomic framework + +Tessa Pierce Ward + +March 2023 + +requires sourmash v4.8+ + +--- + +```{contents} + :depth: 2 +``` + +This tutorial uses the `sourmash taxonomy` module, which was introduced via [blog post](https://bluegenes.github.io/sourmash-tax/) +and was recently shown to perfom well for taxonomic profiling of long (and short) reads in [Evaluation of taxonomic classification and profiling methods for long-read shotgun metagenomic sequencing datasets](https://link.springer.com/article/10.1186/s12859-022-05103-0), Portik et al., 2022. + + +In this tutorial, we'll use sourmash gather to analyze metagenomes using the [LIN taxonomic framework](https://dl.acm.org/doi/pdf/10.1145/3535508.3545546). +Specifically, we will analyze plant metagenomes with a low-level pathogen spike-in. +The goal is to see if we can correctly assign the pathogen sequence to its LINgroup, which includes +all known pathogenic strains. + +- `barcode1` - highest spike-in (75 picogram/microliter pathogen DNA) +- `barcode3` - lower spike-in (7.5 picogram/microliter pathogen DNA) +- `barcode5` - no spike-in + +The pathogen is `Ralstonia solanacearum` in the `Phylum IIB sequevar 1` group. + +This data is courtesy of [The Laboratory of Plant & Atmospheric Microbiology & (Meta)Genomics](https://sites.google.com/vt.edu/lab-vinatzer/home) in collaboration with USDA APHIS. + +## Install sourmash + +First, we need to install the software! We'll use conda/mamba to do this. + +The below command installs [sourmash](http://sourmash.readthedocs.io/). + +Install the software: +``` +# create a new environment +mamba create -n smash -y -c conda-forge -c bioconda sourmash +``` + +then activate the conda environment: +``` +conda activate smash +``` + +> Victory conditions: your prompt should start with +> `(smash) ` +> and you should now be able to run `sourmash` and have it output usage information!! + +## Create a working subdirectory + +Make a directory named `smash_lin`, change into it: +``` +mkdir -p ~/smash_lin +cd ~/smash_lin +``` + +Now make a couple useful folders: +``` +mkdir -p inputs +mkdir -p databases +``` + +## Download relevant data + +### First, download a database and taxonomic information + +Here, we know the spike-in is a pathogenic seqevar of Ralstonia. We will download a database +containing signatures of 27 Ralstonia genomes (pathogenic and not) and the corresponding taxonomic and lingroup information. + +``` +# database +curl -JLO https://osf.io/vxsta/download +mv ralstonia*.zip ./databases/ralstonia.zip + +# taxonomy csv +curl -JLO https://raw.githubusercontent.com/bluegenes/2023-demo-sourmash-LIN/main/databases/ralstonia-lin.taxonomy.GCA-GCF.csv +mv ralstonia-lin.taxonomy.GCA-GCF.csv ./databases + +# lingroup csv +curl -JLO https://raw.githubusercontent.com/bluegenes/2023-demo-sourmash-LIN/main/inputs/ralstonia.lingroups.csv +mv ralstonia.lingroups.csv ./databases + +ls databases # look at the database files +``` + +### Next, download pre-made sourmash signatures made from the input metagenomes + +``` +# download barcode 1 sig +curl -JLO https://osf.io/ujntr/download +mv barcode1_22142.sig.zip ./inputs/ + +# download barcode 3 signature +curl -JLO https://osf.io/2h9wx/download +mv barcode3_31543.sig.zip ./inputs + +# download barcode 5 signature +curl -JLO https://osf.io/k8nw5/download +mv barcode5_36481.sig.zip ./inputs + +# look at available input files +ls inputs +``` + +## Look at the signatures + +Let's start with the `barcode1` (highest spike-in) sample + +### First, let's look at the metagenome signature. + +By running `sourmash sig fileinfo`, we can see information on the signatures available within the zip file. + +Here, you can see I've generated the metagenome signature with `scaled=1000` and built two ksizes, `k=31` and `k=51` + +Run: +``` +sourmash sig fileinfo ./inputs/barcode1_22142.sig.zip +``` + +In the output, you should see: +``` +** loading from './inputs/barcode1_22142.sig.zip' +path filetype: ZipFileLinearIndex +location: /home/jovyan/smash_lin/inputs/barcode1_22142.sig.zip +is database? yes +has manifest? yes +num signatures: 2 +total hashes: 914328 +summary of sketches: + 1 sketches with DNA, k=31, scaled=1000, abund 426673 total hashes + 1 sketches with DNA, k=51, scaled=1000, abund 487655 total hashes +``` + +### We can also look at the database + +Here, you can see I've generated the database with `scaled=1000` and built three ksizes, `k=21`, `k=31` and `k=51` + +Run: +``` +sourmash sig fileinfo ./databases/ralstonia.zip +``` + +In the output, you should see: + +``` +** loading from './databases/ralstonia.zip' +path filetype: ZipFileLinearIndex +location: /home/jovyan/databases/ralstonia.zip +is database? yes +has manifest? yes +num signatures: 81 +** examining manifest... +total hashes: 445041 +summary of sketches: + 27 sketches with DNA, k=21, scaled=1000, abund 148324 total hashes + 27 sketches with DNA, k=31, scaled=1000, abund 148111 total hashes + 27 sketches with DNA, k=51, scaled=1000, abund 148606 total hashes +``` +There's a lot of things to digest in this output but the two main ones are: +* there are 27 genomes represented in this database, each of which are sketched at k=21,k=31,k=51 +* this database represents ~445 *million* k-mers (multiply number of hashes by the scaled number) + + +## Run sourmash gather using ksize 51 + +Now let's run `sourmash gather` to find the closest reference genome(s) in the database. +If you want to read more about what sourmash is doing, please see [Lightweight compositional analysis of metagenomes with FracMinHash and minimum metagenome covers](https://www.biorxiv.org/content/10.1101/2022.01.11.475838v2), Irber et al., 2022. + +Run: +``` +query="inputs/barcode1_22142.sig.zip" +database="databases/ralstonia.zip" + +gather_csv_output="barcode1_22141.k51.gather.csv" + +sourmash gather $query $database -k 51 -o $gather_csv_output +``` + +You should see the following output: +``` +selecting specified query k=51 +loaded query: barcode1_22142... (k=51, DNA) +--ading from 'databases/ralstonia.zip'... +loaded 81 total signatures from 1 locations. +after selecting signatures compatible with search, 27 remain. +Starting prefetch sweep across databases. + +Found 7 signatures via prefetch; now doing gather. + +overlap p_query p_match avg_abund +--------- ------- ------- --------- +105.0 kbp 0.0% 2.0% 1.0 GCA_002251655.1 Ralstonia solanacear... +found less than 50.0 kbp in common. => exiting + +found 1 matches total; +the recovered matches hit 0.0% of the abundance-weighted query. +the recovered matches hit 0.0% of the query k-mers (unweighted). +``` + +The first step of gather found all potential matches (7), and the greedy algorithm narrowed this to a single best match, `GCA_002251655.1` which shared an estimated 105 kbp with the metagenome (a very small percentage of the total dataset.) This is expected, though, since the dataset is a plant metagenome with a small `Ralstonia` spike-in. + +## Add taxonomic information and summarize up lingroups + +`sourmash gather` finds the smallest set of reference genomes that contains all the known information (k-mers) in the metagenome. In most cases, `gather` will find many metagenome matches. Here, we're only looking for `Ralstonia` matches and we only have a single gather result. Regardless, let's use `sourmash tax metagenome` to add taxonomic information and see if we've correctly assigned the pathogenic sequence. + +### First, let's look at the relevant taxonomy files. + +These commands will show the first few lines of each file. If you prefer, you can look at a more human-friendly view by opening the files in a spreadsheet program. + +- **taxonomy_csv:** `databases/ralstonia-lin.taxonomy.GCA-GCF.csv` + - the essential columns are `lin` (`14;1;0;...`) and `ident` (`GCF_00`...) +- **lingroups information:** `databases/ralstonia.lingroups.csv` + - both columns are essential (`name`, `lin`) + + +Look at the taxonomy file: +``` +head -n 5 databases/ralstonia-lin.taxonomy.GCA-GCF.csv +``` + +You should see: +``` +lin,species,strain,filename,accession,ident +14;1;0;0;0;0;0;0;0;0;6;0;1;0;1;0;0;0;0;0,Ralstonia solanacearum,OE1_1,GCF_001879565.1_ASM187956v1_genomic.fna,GCF_001879565.1,GCF_001879565.1 +14;1;0;0;0;0;0;0;0;0;6;0;1;0;0;0;0;0;0;0,Ralstonia solanacearum,PSS1308,GCF_001870805.1_ASM187080v1_genomic.fna,GCF_001870805.1,GCF_001870805.1 +14;1;0;0;0;0;0;0;0;0;2;1;0;0;0;0;0;0;0;0,Ralstonia solanacearum,FJAT_1458,GCF_001887535.1_ASM188753v1_genomic.fna,GCF_001887535.1,GCF_001887535.1 +14;1;0;0;0;0;0;0;0;0;2;0;0;4;4;0;0;0;0;0,Ralstonia solanacearum,Pe_13,GCF_012062595.1_ASM1206259v1_genomic.fna,GCF_012062595.1,GCF_012062595.1 +``` +> The key columns are: +> - `ident`, containing identifiers matching the database sketches +> - `lin`, containing the species information. + +Now, let's look at the lingroups file +``` +head -n5 databases/ralstonia.lingroups.csv +``` + +You should see: +``` +name,lin +Phyl II,14;1;0;0;0;3;0 +Phyl IIA,14;1;0;0;0;3;0;1;0;0 +Phyl IIB,14;1;0;0;0;3;0;0 +Phyl IIB seq1 and seq2,14;1;0;0;0;3;0;0;0;0;1;0;0;0;0 +``` +> Here, we have two columns: +> - `name` - the name for each lingroup. +> - `lin` - the LIN prefix corresponding to each group. + + +### Now, run `sourmash tax metagenome` to integrate taxonomic information into `gather` results + +Using the `gather` output we generated above, we can integrate taxonomic information and summarize up "ranks" (lin positions). We can produce several different types of outputs, including a `lingroup` report. + +`lingroup` format summarizes the taxonomic information at each `lingroup`, and produces a report with 4 columns: +- `name` (from lingroups file) +- `lin` (from lingroups file) +- `percent_containment` - total % of the file matched to this lingroup +- `num_bp_contained` - estimated number of bp matched to this lingroup + +> Since sourmash assigns all k-mers to individual genomes, no reads/base pairs are "assigned" to higher taxonomic ranks or lingroups (as with Kraken-style LCA). Here, "percent_containment" and "num_bp_contained" is calculated by summarizing the assignments made to all genomes in a lingroup. This is akin to the "contained" information in Kraken-style reports. + +Run `tax metagenome`: +``` +gather_csv_output="barcode1_22141.k51.gather.csv" +taxonomy_csv="databases/ralstonia-lin.taxonomy.GCA-GCF.csv" +lingroups_csv="databases/ralstonia.lingroups.csv" + +sourmash tax metagenome -g $gather_csv_output -t $taxonomy_csv \ + --lins --lingroup $lingroups_csv +``` + +You should see: +``` +loaded 1 gather results from 'barcode1_22141.k51.gather.csv'. +loaded results for 1 queries from 1 gather CSVs +Starting summarization up rank(s): 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0 +Read 11 lingroup rows and found 11 distinct lingroup prefixes. +``` + +and the results: +``` +name lin percent_containment num_bp_contained +Phyl II 14;1;0;0;0;3;0 0.02 108000 +Phyl IIB 14;1;0;0;0;3;0;0 0.02 108000 +Phyl IIB seq1 and seq2 14;1;0;0;0;3;0;0;0;0;1;0;0;0;0 0.02 108000 +IIB seq1 14;1;0;0;0;3;0;0;0;0;1;0;0;0;0;0;0 0.02 108000 +``` + +Here, the most specific lingroup we assign to is `Phyl IIB seq1`, which is the pathogenic lingroup that was spiked in, yay! Note that the other groups in the output all contain this group. + + +#### Now output the lingroup report to a file (instead of to the terminal) + +use `-o` to provide an output basename for taxonomic output. + +``` +gather_csv_output="barcode1_22141.k51.gather.csv" +taxonomy_csv="databases/ralstonia-lin.taxonomy.GCA-GCF.csv" +lingroups_csv="databases/ralstonia.lingroups.csv" + +sourmash tax metagenome -g $gather_csv_output -t $taxonomy_csv \ + --lins --lingroup $lingroups_csv \ + -o "barcode1" +``` + +> You should see `saving 'lingroup' output to 'barcode1.lingroup.tsv'` in the output. + +#### Optionally, write multiple output formats + +You can use `-F` to specify additional output formats. Here, I've added `csv_summary`. Note that while the `lingroup` format will be generated automatically if you specify the `--lingroup` file, you can also specify it with `-F lingroup` if you want, as I've done here. + +Run: +``` +gather_csv_output="barcode1_22141.k51.gather.csv" +taxonomy_csv="databases/ralstonia-lin.taxonomy.GCA-GCF.csv" +lingroups_csv="databases/ralstonia.lingroups.csv" + +sourmash tax metagenome -g $gather_csv_output -t $taxonomy_csv \ + --lins --lingroup $lingroups_csv \ + -F lingroup csv_summary -o "barcode1" +``` + + +You should see the following in the output: + +``` +saving 'csv_summary' output to 'barcode1.summarized.csv'. +saving 'lingroup' output to 'barcode1.lingroup.txt'. +``` + +The `csv_summary` format is the **full** summary of this sample, e.g. the summary at each taxonomic rank (LIN position). It also includes an entry with the `unclassified` portion at each rank. + +> Note: Multiple output formats require the `-o` `--output-base` to be specified, as each must be written to a file. + +Here's an abbreviated version of the `gather` results for `barcode1`, with lingroup information added: + +| | **ksize** | **scaled** | **best overlap** | **gather match(es)** | **lingroup** | **lin** | +| ------- | --------- | ---------- | ---------------- | -------------------- | ------------ | ---------------------------------- | +| **bc1** | 51 | 1000 | 105 kb | GCA_002251655.1 | IIB seq1 | 14;1;0;0;0;3;0;0;0;0;1;0;0;0;0;0;0 | +| **bc1** | 31 | 1000 | 173 kb | GCA_002251655.1 | IIB seq1 | 14;1;0;0;0;3;0;0;0;0;1;0;0;0;0;0;0 | + + +### Now run with `barcode3` sample + +#### sourmash gather +Run: +``` +query="inputs/barcode3_31543.sig.zip" +database="databases/ralstonia.zip" + +gather_csv_output="barcode3_31543.dna.k51.gather.csv" + +sourmash gather $query $database -k 51 -o $gather_csv_output +``` + +You should see: +``` +selecting specified query k=51 +loaded query: barcode3_31543... (k=51, DNA) +loading from 'databases/ralstonia.zip'... +loaded 81 total signatures from 1 locations. +after selecting signatures compatible with search, 27 remain. +Starting prefetch sweep across databases. +Found 0 signatures via prefetch; now doing gather. +found less than 50.0 kbp in common. => exiting + + +found 0 matches total; +the recovered matches hit 0.0% of the query k-mers (unweighted). +``` + +#### gather found no sequence matches! But, we can lower the detection threshold: + +``` +query="inputs/barcode3_31543.sig.zip" +database="databases/ralstonia.zip" +gather_csv_output="barcode3_31543.k51.gather.csv" + +# use a 10kb detection threshold +sourmash gather $query $database -k 51 --threshold-bp 10000 -o $gather_csv_output +``` + +This time, you should see: +``` +selecting specified query k=51 +loaded query: barcode3_31543... (k=51, DNA) +loading from 'databases/ralstonia.zip'... +loaded 81 total signatures from 1 locations. +after selecting signatures compatible with search, 27 remain. +Starting prefetch sweep across databases. +Found 6 signatures via prefetch; now doing gather. + + +overlap p_query p_match avg_abund +--------- ------- ------- --------- +12.0 kbp 0.0% 0.2% 1.0 GCA_000750575.1 Ralstonia solanacear... + +found 1 matches total; +the recovered matches hit 0.0% of the abundance-weighted query. +the recovered matches hit 0.0% of the query k-mers (unweighted). + +``` + +You'll notice that while we have an estimated ~12kbp overlap, the matched genome (`GCA_000750575.1`) is different from the one matched above for `barcode5`. If you run `sourmash tax metagenome` on this output, you'll see that this genome belongs to `Phyl IIB seq 2` group, which is a sister group to the correct `Phyl IIB seq 1` group that we expected. So we have a match but it's not the right one -- why not? + +### What happened? Use `prefetch` to investigate + +`sourmash gather` has two steps: first, it runs a `prefetch` to find ALL genome matches, and then uses a greedy approach to select the smallest set of genomes that contain ('cover') all known sequence content. Let's run `prefetch` independently so we can look at the results of the first step. Here, let's use `--threshold-bp 0` to get all possible matches. + +Run: +``` +query="inputs/barcode3_31543.sig.zip" +prefetch_csv_output="barcode3_31543.k51.prefetch.csv" +database="databases/ralstonia.zip" + +sourmash prefetch $query $database -k 51 --threshold-bp 0 -o $prefetch_csv_output +``` + +You should see: +``` +selecting specified query k=51 +loaded query: barcode3_31543... (k=51, DNA) +query sketch has scaled=1000; will be dynamically downsampled as needed. +--tal of 10 matching signatures so far.tonia.zip' +loaded 81 total signatures from 1 locations. +after selecting signatures compatible with search, 27 remain. +-- +total of 15 matching signatures. +saved 15 matches to CSV file 'barcode3_31543.k51.prefetch.csv' +of 487043 distinct query hashes, 12 were found in matches above threshold. +a total of 487031 query hashes remain unmatched. +final scaled value (max across query and all matches) is 1000 +``` + +Here, the output is telling us we found matches to 15 of the 27 Ralstonia genomes. But only **12 k-mers** were shared between the metagenome sample and the genomes. Remember that sourmash uses a representative subsample of all k-mers, so here these 12 k-mers represent ~ 12kb of sequence (12 * scaled). We've found that this is sufficient to detect presence of an organism, but at this low level, it can be hard to distinguish between closely-related genomes. Let's open the prefetch output to see how those 12 k-mers matched between different genomes. + +#### Look at the `barcode3_31543.k51.prefetch.csv` file + +> Use a spreadsheet program on your computer or use `less -S barcode3_31543.k51.prefetch.csv` to see the file on the terminal. If using `less`, hit `q` when you want to exit and return to your terminal prompt. + +The first column contains the estimated number of base pairs matched between our query and each matching reference genome. You'll notice there are four genomes that match 12kb of sequence, one of which is the "correct" genome (`GCA_002251655.1`, which is in the `IIB seq1` lingroup). + +**What is happening here?** + +When faced with equally good matches, `sourmash gather` makes a random choice about which genome to assign these k-mers to. This happens primarily with highly similar genomes and/or very small sequence matches. If this happens and you need to distinguish between these genomes, we recommend trying a lower scaled value (higher resolution). "scaled" refers to the systematic downsampling: we keep rougly 1/scaled k-mers (`scaled=1000` keeps ~1 of every 1000 unique k-mers). `scaled=1` keeps all k-mers, but our signature storage is not optimized for this use case. + +To see if we could robustly assign the correct sequevar for `barcode3` using a higher resolution sketch, I also ran `gather` using `scaled=100`. + +Here's an abbreviated version of the `gather` results for `barcode3`, with lingroup information added: + + + +| | **ksize** | **scaled** | **best overlap** | **gather match(es)** | **lingroup** | **lin** | +| ------- | --------- | ---------- | ---------------- | -------------------- | ------------ | ---------------------------------- | +| **bc3** | 51 | 1000 | 12kb | GCA_000750575.1 | IIB seq2 | 14;1;0;0;0;3;0;0;0;0;1;0;0;0;0;1;0 | +| **bc3** | 31 | 1000 | 28 kb | GCA_002251655.1 | IIB seq1 | 14;1;0;0;0;3;0;0;0;0;1;0;0;0;0;0;0 | +| **bc3** | 51 | 100 | 14.8 kb | GCA_002251655.1 | IIB seq1 | 14;1;0;0;0;3;0;0;0;0;1;0;0;0;0;0;0 | +| **bc3** | 31 | 100 | 21.1 kb | GCA_002251655.1 | IIB seq1 | 14;1;0;0;0;3;0;0;0;0;1;0;0;0;0;0;0 | + +We typically use k=51 for strain-level matching and k=31 for species-level matching. Notice that running at k=31 with scaled 1000 found the right match. However, if you run prefetch for `k=31`, you see there are three matches with `28kb` overlap, so we just got lucky that `gather` selected the right one for this test case. + +In contrast, by sketching the `Ralstonia` genomes and metagenome at higher resolution (`scaled=100`), we had sufficient information to correctly assign the sequence to the `IIB seq1` lingroup at either ksize. + + +### Now try the `barcode5` sample + +You can also run the `barcode5` file using the same commands as above: + +``` +query="inputs/barcode5_36481.sig.zip" +database="databases/ralstonia.zip" + +gather_csv_output="barcode5_36481.dna.k51.gather.csv" + +sourmash gather $query $database -k 51 -o $gather_csv_output +``` + +You should see: + +``` +selecting specified query k=51 +loaded query: barcode5_36481... (k=51, DNA) +-- +loaded 81 total signatures from 1 locations. +after selecting signatures compatible with search, 27 remain. + +Starting prefetch sweep across databases. +Found 0 signatures via prefetch; now doing gather. +found less than 50.0 kbp in common. => exiting + +found 0 matches total; +the recovered matches hit 0.0% of the query k-mers (unweighted). +``` + + +No matches are found. If you drop the threshold-bp to 0 (`--threshold-bp 0`), you can find ~1kbp overlap (a single k-mer match!). **Note, we do not recommend trusting/using results with fewer than 3 k-mer matches (3kbp at scaled=1000)**. Especially in larger databases (e.g. NCBI/GTDB), a single k-mer match might actually be from contamination in the reference genome rather than true genome content, so you may end up assigning the wrong lineage. Requiring 3 k-mers (representing ~3kb of matching sequence) makes it more likely your matches represent true genome content. + +I then ran this file at higher resolution to see how the results changed. In each case, very few k-mers matched and we could not robustly identify a specific `Ralstonia` genome or lingroup. As it turns out, `barcode5` does not have a `Ralstonia` spike-in, so this is a good thing! + +Here's an abbreviated version of the `gather` results for `barcode5`, with lingroup information added in cases with a single gather match: + +| | **ksize** | **scaled** | **best overlap** | **gather match(es)** | **lingroup** | **lin** | +| ------- | --------- | ---------- | ---------------- | -------------------- | ------------ | ---------------------------------- | +| **bc5** | 51 | 1000 | 1 kbp | GCA_000750575.1 | IIB seq2 | 14;1;0;0;0;3;0;0;0;0;1;0;0;0;0;1;0 | +| **bc5** | 31 | 1000 | 0 | N/A | | | +| **bc5** | 51 | 100 | 300bp | all | | | +| **bc5** | 31 | 100 | 1.2 kb | all | | | +| **bc5** | 51 | 10 | 120 bp | all | | | +| **bc5** | 31 | 10 | 670 bp | all | | | +| **bc5** | 51 | 5 | 150 bp | all | | | +| **bc5** | 31 | 5 | 500 bp | all | | | + + +**Again, while I've used a threshold-bp of 0 to get the gather match at scaled=1000, we do not typically trust gather matches with less than `3*scaled` overlap (< 3 k-mers matched).** Even at very high resolution (scaled=5), we matched nearly all Ralstonia genomes and could not distinguish a single lingroup. + +We typically recommend running at `scaled=1000` (our default), as this works for most microbial use cases. You can run at higher resolution (lower scaled) if you need to, but higher resolution signatures are larger and can take significantly longer to build and search - use at your own risk :). + +## Summary and concluding thoughts + +The LIN taxonomic framework may be useful distinguishing groups below the species level. +We can now use LINs and lingroups with `sourmash tax metagenome`. For low level matches, the gather greedy +approach can struggle. We are working on ways to better warn users about this behavior and welcome +feedback and suggestions on our [issue tracker](https://github.com/sourmash-bio/sourmash/issues/new). \ No newline at end of file diff --git a/doc/tutorial-long.md b/doc/tutorial-long.md new file mode 100644 index 0000000000..149f377954 --- /dev/null +++ b/doc/tutorial-long.md @@ -0,0 +1,567 @@ +# Quick Insights from Sequencing Data with sourmash + +Note: this tutorial was developed for and first presented at +[ANGUS 2019](https://angus.readthedocs.io/en/2019/sourmash.html). + +## Objectives +1. Discuss k-mers and their utility +2. Compare RNA-seq samples quickly +3. Detect eukaryotic contamination in raw RNA-seq reads +4. Compare reads to an assembly +5. Build your own database for searching +6. Other sourmash databases + +## Introduction to k-mers + +A "k-mer" is a word of DNA that is k long: + +``` +ATTG - a 4-mer +ATGGAC - a 6-mer +``` + +Typically we extract k-mers from genomic assemblies or read data sets by running a k-length window across all of the reads and sequences -- e.g. given a sequence of length 16, you could extract 11 k-mers of length six from it like so: + +``` +AGGATGAGACAGATAG +``` +becomes the following set of 6-mers: +``` +AGGATG + GGATGA + GATGAG + ATGAGA + TGAGAC + GAGACA + AGACAG + GACAGA + ACAGAT + CAGATA + AGATAG +``` + +Today we will be using a tool called [sourmash](https://f1000research.com/articles/8-1006) +to explore k-mers! + +## Why k-mers, though? Why not just work with the full read sequences? + +Computers *love* k-mers because there's no ambiguity in matching them. You either have an exact match, or you don't. And computers love that sort of thing! + +Basically, it's really easy for a computer to tell if two reads share a k-mer, and it's pretty easy for a computer to store all the k-mers that it sees in a pile of reads or in a genome. + +## Long k-mers are species specific + +k-mers are most useful when they're *long*, because then they're *specific*. That is, if you have a 31-mer taken from a human genome, it's pretty unlikely that another genome has that exact 31-mer in it. (You can calculate the probability if you assume genomes are random: there are 431 possible 31-mers, and 431 = 4,611,686,018,427,387,904\. So, you know, a lot.) + +Essentially, *long k-mers are species specific*. Check out this figure from the [MetaPalette paper](http://msystems.asm.org/content/1/3/e00020-16): + +![](_static/kmers-metapalette.png) + +Here, Koslicki and Falush show that k-mer similarity works to group microbes by genus, at k=40\. If you go longer (say k=50) then you get only very little similarity between different species. + +## Using k-mers to compare samples + +So, one thing you can do is use k-mers to compare read data sets to read data sets, or genomes to genomes: data sets that have a lot of similarity probably are similar or even the same genome. + +One metric you can use for this comparisons is the Jaccard distance, which is calculated by asking how many k-mers are *shared* between two samples vs how many k-mers in total are in the combined samples. + +``` +only k-mers in both samples +---------------------------- +all k-mers in either or both samples +``` + +A Jaccard distance of 1 means the samples are identical; a Jaccard distance of 0 means the samples are completely different. + +Jaccard distance works really well when we don't care how many times we see a k-mer. +When we keep track of the abundance of a k-mer, say for example in RNA-seq samples where the number of read counts matters, we use cosine distance instead. + +These two measures can be used to search databases, compare RNA-seq samples, and all sorts of other things! The only real problem with it is that there are a *lot* of k-mers in a genome -- a 5 Mbp genome (like E. coli) has 5 m k-mers! + +About two years ago, [Ondov et al. (2016)](https://genomebiology.biomedcentral.com/articles/10.1186/s13059-016-0997-x) showed that [MinHash approaches](https://en.wikipedia.org/wiki/MinHash) could be used to estimate Jaccard distance using only a small fraction (1 in 10,000 or so) of all the k-mers. + +The basic idea behind MinHash is that you pick a small subset of k-mers to look at, and you use those as a proxy for *all* the k-mers. The trick is that you pick the k-mers randomly but consistently: so if a chosen k-mer is present in two data sets of interest, it will be picked in both. This is done using a clever trick that we can try to explain to you in class - but either way, trust us, it works! + +We have implemented a MinHash approach in our [sourmash software](https://github.com/sourmash-bio/sourmash/), which can do some nice things with samples. We'll show you some of these things next! + +## Installing sourmash + +To install sourmash, run: + +``` +conda install -y -c conda-forge -c bioconda sourmash +``` + +## Creating signatures + +A signature is a compressed representation of the k-mers in the sequence. + +Depending on your application, we recommend different ways of preparing sequencing data to create a signature. + +In a genome or transcriptome, we expect that the k-mers we see are accurate. We can create +signatures from these type of sequencing data sets without any preparation. We demonstrate +how to create a signature from high-quality sequences below. + +First, download a genome assembly: + +``` +cd ~ +mkdir sourmash_data +cd sourmash_data + +curl -L https://osf.io/963dg/download -o ecoliMG1655.fa.gz +gunzip -c ecoliMG1655.fa.gz | head +``` + +Compute a scaled MinHash from the assembly: + +``` +sourmash compute -k 21,31,51 --scaled 2000 --track-abundance -o ecoliMG1655.sig ecoliMG1655.fa.gz +``` + +For raw sequencing reads, we expect that many of the unique k-mers we observe will be due +to errors in sequencing. Unlike with high-quality sequences like transcriptomes and +genomes, we need to think carefully about how we want to create each signature, as it will +depend on the downstream application. + ++ **Comparing reads against high quality sequences**: Because our references that we are +comparing or searching against only contain k-mers that are likely real, we don't want to +trim potentially erroneous k-mers. Although most of the k-mers would be errors that we +would trim, there is a chance we could accidentally remove **real** biological variation +that is present at low abundance. Instead, we only want to trim adapters. ++ **Comparing reads against other reads**: Because both datasets likely have many +erroneous k-mers, we want to remove the majority of these so as not to falsely deflate +similarity between samples. Therefore, we want to trim what are likely erroneous k-mers +from sequencing errors, as well as adapters. + +Let's download some raw sequencing reads and demonstrate what k-mer trimming looks like. + +First, download a read file: + +``` +curl -L https://osf.io/pfxth/download -o ERR458584.fq.gz +gunzip -c ERR458584.fq.gz | head +``` + +Next, perform k-mer trimming using a library called khmer. K-mer trimming removes +low-abundant k-mers from the sample. + +``` +trim-low-abund.py ERR458584.fq.gz -V -Z 10 -C 3 --gzip -M 3e9 -o ERR458584.khmer.fq.gz +``` + +Finally, calculate a signature from the trimmed reads. +``` +sourmash compute -k 21,31,51 --scaled 2000 --track-abundance -o ERR458584.khmer.sig ERR458584.khmer.fq.gz +``` + +![qc](_static/Sourmash_flow_diagrams_QC.png) + + +![compute](_static/Sourmash_flow_diagrams_compute.png) + +We can prepare signatures like this for any sequencing data file! For the rest of the +tutorial, we have prepared signatures for each sequencing data set we will be working +with. + +## Compare many RNA-seq samples quickly + +Use case: how similar are my samples to one another? + +Traditionally in RNA-seq workflows, we use MDS plots to determine how similar our samples +are. Samples that are closer together on the MDS plot are more similar. However, to get +to this point, we have to trim our reads, download or build a reference transcriptome, +quantify our reads using a tool like Salmon, and then read the counts into R and make an +MDS plot. This is a lot of steps to go through just to figure out how similar your samples +are! + +Luckily, we can use sourmash to quickly compare how similar our samples are. + +We [generated signatures](https://github.com/taylorreiter/yeast-rna-sigs/blob/master/Snakefile) +for the majority of the rest of the +[Schurch et al. experiment](https://www.ncbi.nlm.nih.gov/pmc/articles/PMC4878611/) we +have been working with this week. Below we download and compare the 647 signatures, and +then produce a plot that shows how similar they are to one another. + +First, download and uncompress the signatures. + +``` +curl -o schurch_sigs.tar.gz -L https://osf.io/p3ryg/download +tar xf schurch_sigs.tar.gz +``` + +Next, compare the signatures using sourmash. + +``` +sourmash compare -k 31 -o schurch_compare_matrix schurch_sigs/*sig +``` + +This outputs a comparison matrix and a set of labels. The matrix is symmetrical, and +contains numbers 0-1 that captures similarity between samples. 0 means there are no +k-mers in common between two samples, while 1 means all k-mers are shared. + +Lastly, we plot the comparison matrix. + +``` +sourmash plot --labels schurch_compare_matrix +``` + +![](_static/schurch_comp.matrix.png) + +We see there are two major blocks of similar samples, which makes sense given that we have +WT and SNF2 knockout samples. However, we also see that some of our samples are outliers! +If this were our experiment, we would want to investigate the outliers further to see +what caused them to be so dissimilar. + +## Detect Eukaryotic Contamination in Raw RNA Sequencing data + +Use case: Search for the presence of unexpected organisms in raw RNA-seq reads + +For most analysis pipelines, there are many steps that need to be executed before we get +to the analysis and interpretation of what is in our sample. This often means we are 10-15 +steps into our analysis before we find any problems. However, if our reads contain +contamination, we want to know that as quickly as possible so we can remove the +contamination and solve any issues that led to the contamination. + +Using sourmash, we can quickly check if we have any unexpected organisms in our sequencing +samples. We do this by comparing a signature from our reads against a database of known +signatures from publicly available reference sequences. + +We have generated sourmash databases for all publicly available Eukaryotic RNA samples +(we used the `*rna_from_genomic*` files from RefSeq and Genbank...however keep in mind +that not all sequenced genomes have these files!). This database includes +fungi, plants, vertebrates, invertebrates, and protazoa. It does not include human, so we +incorporate that separately. We also built another database of the ~700 recently +[re-assembled](https://academic.oup.com/gigascience/article/8/4/giy158/5241890) marine +transcriptomes from the +[MMETSP project](https://journals.plos.org/plosbiology/article?id=10.1371/journal.pbio.1001889). +These databases allow us to detect common organisms that might be unexpectedly present +in our sequencing data. + +First, let's download and uncompress our three databases: human, MMETSP, and everything +else! + +``` +wget -O sourmash_euk_rna_db.tar.gz https://osf.io/vpk8s/download +tar xf sourmash_euk_rna_db.tar.gz +``` + +Next, let's download a signature from some sequencing reads. We'll work with some +sequencing reads from a wine fermentation. + +``` +wget -O wine.sig https://osf.io/5vsjq/download +``` + +We expected fungus and grape to be metabolically active in these samples. Let's check +which organisms we detect. + +``` +sourmash gather -k 31 --scaled 2000 -o wine.csv wine.sig sourmash_euk_rna_db/*sbt.json sourmash_euk_rna_db/*sig +``` + +If we take a look at the output, we see: + +``` +== This is sourmash version 2.0.1. == +== Please cite Brown and Irber (2016), doi:10.21105/joss.00027. == + +loaded query: wine_fermentation... (k=31, DNA) +downsampling query from scaled=2000 to 2000 +loaded 1 signatures and 2 databases total. + + +overlap p_query p_match avg_abund +--------- ------- ------- --------- +2.2 Mbp 79.0% 28.5% 122.2 Sc_YJM1477_v1 Saccharomyces cerevis... +0.8 Mbp 1.4% 2.2% 6.2 12X Vitis vinifera (wine grape) +2.1 Mbp 0.4% 1.6% 9.3 GLBRCY22-3 Saccharomyces cerevisiae... +124.0 kbp 0.1% 0.7% 3.1 Aureobasidium pullulans var. pullula... +72.0 kbp 0.0% 0.1% 1.9 Mm_Celera Mus musculus (house mouse) +1.9 Mbp 0.0% 0.5% 3.7 Sc_YJM1460_v1 Saccharomyces cerevis... +1.8 Mbp 0.1% 0.5% 14.1 ASM18217v1 Saccharomyces cerevisiae... +2.1 Mbp 0.1% 0.3% 17.7 R008 Saccharomyces cerevisiae R008 ... +1.9 Mbp 0.0% 0.1% 3.1 ASM32610v1 Saccharomyces cerevisiae... +found less than 18.0 kbp in common. => exiting + +found 9 matches total; +the recovered matches hit 81.2% of the query +``` + +...which is almost exactly what we expect, except we see some house mouse! And I promise +Ratatouille was not making this wine. + +Using this method, we have now identified contamination in our reads. We could align to +the mouse genome to remove these reads, however the best strategy to remove these reads +may vary on a case by case basis. + +## Compare reads to assemblies + +Use case: how much of the read content is contained in the reference genome? + +First we’ll download some reads from an E. coli genome, then we will generate a signature from them + +``` +curl -L https://osf.io/frdz5/download -o ecoli_ref-5m.fastq.gz +sourmash compute -k 31 --scaled 2000 ~/sourmash_data/ecoli_ref-5m.fastq.gz -o ecoli-reads.sig +``` + +![search](_static/Sourmash_flow_diagrams_search.png) + +Build a signature for an E. coli genome: + +``` +sourmash compute --scaled 2000 -k 31 ~/sourmash_data/ecoliMG1655.fa.gz -o ecoli-genome.sig +``` + +and now evaluate *containment*, that is, what fraction of the read content is +contained in the genome: + +``` +sourmash search -k 31 ecoli-reads.sig ecoli-genome.sig --containment +``` + +and you should see: + +``` +loaded query: /home/diblions/data/ecoli_ref-... (k=31, DNA) +loaded 1 signatures. + +1 matches: +similarity match +---------- ----- + 9.7% /home/diblions/data/ecoliMG1655.fa.gz +``` + +Why are only 10% or so of our k-mers from the reads in the genome!? +Any ideas? + +Try the reverse - why is it bigger? + +``` +sourmash search -k 31 ecoli-genome.sig ecoli-reads.sig --containment +``` + +(...but 100% of our k-mers from the genome are in the reads!?) + + +## Make and search a database quickly. + +Suppose that we have a collection of signatures (made with `sourmash +compute` as above) and we want to search it with our newly assembled +genome (or the reads, even!). How would we do that? + +Let's grab a sample collection of 50 E. coli genomes and unpack it -- + +``` +mkdir ecoli_many_sigs +cd ecoli_many_sigs + +curl -O -L https://github.com/sourmash-bio/sourmash/raw/master/data/eschericia-sigs.tar.gz + +tar xzf eschericia-sigs.tar.gz +rm eschericia-sigs.tar.gz + +cd ../ +``` + +This will produce 50 files named `ecoli-N.sig` in the `ecoli_many_sigs` -- + +``` +ls ecoli_many_sigs +``` + +Let's turn this into an easily-searchable database with `sourmash index` -- + +``` +sourmash index -k 31 ecolidb ecoli_many_sigs/*.sig +``` + +One point to make with this is that the search can quickly narrow down +which signatures match your query, without losing any matches. It's a +clever example of how computer scientists can actually make life +better :). + +---- + +And now we can search! + +``` +sourmash search ecoli-genome.sig ecolidb.sbt.json -n 20 +``` + +You should see output like this: + +``` +# running sourmash subcommand: search +select query k=31 automatically. +loaded query: /home/tx160085/data/ecoliMG165... (k=31, DNA) +loaded SBT ecolidb.sbt.json +Searching SBT ecolidb.sbt.json +49 matches; showing first 20: +similarity match +---------- ----- + 75.9% NZ_JMGW01000001.1 Escherichia coli 1-176-05_S4_C2 e117605... + 73.0% NZ_JHRU01000001.1 Escherichia coli strain 100854 100854_1... + 71.9% NZ_GG774190.1 Escherichia coli MS 196-1 Scfld2538, whole ... + 70.5% NZ_JMGU01000001.1 Escherichia coli 2-011-08_S3_C2 e201108... + 69.8% NZ_JH659569.1 Escherichia coli M919 supercont2.1, whole g... + 59.9% NZ_JNLZ01000001.1 Escherichia coli 3-105-05_S1_C1 e310505... + 58.3% NZ_JHDG01000001.1 Escherichia coli 1-176-05_S3_C1 e117605... + 56.5% NZ_MIWF01000001.1 Escherichia coli strain AF7759-1 contig... + 56.1% NZ_MOJK01000001.1 Escherichia coli strain 469 Cleandata-B... + 56.1% NZ_MOGK01000001.1 Escherichia coli strain 676 BN4_676_1_(... + 50.5% NZ_KE700241.1 Escherichia coli HVH 147 (4-5893887) acYxy-... + 50.3% NZ_APWY01000001.1 Escherichia coli 178200 gec178200.conti... + 48.8% NZ_LVOV01000001.1 Escherichia coli strain swine72 swine72... + 48.8% NZ_MIWP01000001.1 Escherichia coli strain K6412 contig_00... + 48.7% NZ_AIGC01000068.1 Escherichia coli DEC7C gecDEC7C.contig.... + 48.2% NZ_LQWB01000001.1 Escherichia coli strain GN03624 GCID_EC... + 48.0% NZ_CCQJ01000001.1 Escherichia coli strain E. coli, whole ... + 47.3% NZ_JHMG01000001.1 Escherichia coli O121:H19 str. 2010EL10... + 47.2% NZ_JHGJ01000001.1 Escherichia coli O45:H2 str. 2009C-4780... + 46.5% NZ_JHHE01000001.1 Escherichia coli O103:H2 str. 2009C-327... + +``` + +identifying what genome is in the signature. Some pretty good matches but nothing above %75. Why? What are some things we should think about when we're doing taxonomic classification? + +## What's in my metagenome? + +First, let's download and upack the database we'll use for classification +``` +cd ~/sourmash_data +curl -L https://osf.io/4f8n3/download -o genbank-k31.lca.json.gz +gunzip genbank-k31.lca.json.gz +``` + +This database is a GenBank index of all +the microbial genomes +-- this one contains sketches of all 87,000 microbial genomes (including viral and fungal). See +[available sourmash databases](http://sourmash.rtfd.io/en/latest/databases.html) +for more information. + +After this database is unpacked, it produces a file +`genbank-k31.lca.json`. + +Next, run the 'lca gather' command to see what's in your ecoli genome -- +``` +sourmash lca gather ecoli-genome.sig genbank-k31.lca.json +``` + +and you should get: + +``` +loaded 1 LCA databases. ksize=31, scaled=10000 +loaded query: /home/diblions/data/ecoliMG165... (k=31) + +overlap p_query p_match +--------- ------- -------- +4.9 Mbp 100.0% 2.3% Escherichia coli + +Query is completely assigned. +``` + +In this case, the output is kind of boring because this is a single +genome. But! You can use this on metagenomes (assembled and +unassembled) as well; you've just got to make the signature files. + +To see this in action, here is gather running on a signature generated +from some sequences that assemble (but don't align to known genomes) +from the +[Shakya et al. 2013 mock metagenome paper](https://www.ncbi.nlm.nih.gov/pubmed/23387867). + +``` +wget https://github.com/sourmash-bio/sourmash/raw/master/doc/_static/shakya-unaligned-contigs.sig +sourmash lca gather shakya-unaligned-contigs.sig genbank-k31.lca.json +``` + +This should yield: +``` +loaded 1 LCA databases. ksize=31, scaled=10000 +loaded query: mqc500.QC.AMBIGUOUS.99.unalign... (k=31) + +overlap p_query p_match +--------- ------- -------- +1.8 Mbp 14.6% 9.1% Fusobacterium nucleatum +1.0 Mbp 7.8% 16.3% Proteiniclasticum ruminis +1.0 Mbp 7.7% 25.9% Haloferax volcanii +0.9 Mbp 7.4% 11.8% Nostoc sp. PCC 7120 +0.9 Mbp 7.0% 5.8% Shewanella baltica +0.8 Mbp 6.0% 8.6% Desulfovibrio vulgaris +0.6 Mbp 4.9% 12.6% Thermus thermophilus +0.6 Mbp 4.4% 11.2% Ruegeria pomeroyi +480.0 kbp 3.8% 7.6% Herpetosiphon aurantiacus +410.0 kbp 3.3% 10.5% Sulfitobacter sp. NAS-14.1 +150.0 kbp 1.2% 4.5% Deinococcus radiodurans (** 1 equal matches) +150.0 kbp 1.2% 8.2% Thermotoga sp. RQ2 +140.0 kbp 1.1% 4.1% Sulfitobacter sp. EE-36 +130.0 kbp 1.0% 0.7% Streptococcus agalactiae (** 1 equal matches) +100.0 kbp 0.8% 0.3% Salinispora arenicola (** 1 equal matches) +100.0 kbp 0.8% 4.2% Fusobacterium sp. OBRC1 +60.0 kbp 0.5% 0.7% Paraburkholderia xenovorans +50.0 kbp 0.4% 3.2% Methanocaldococcus jannaschii (** 2 equal matches) +50.0 kbp 0.4% 0.3% Bacteroides vulgatus (** 1 equal matches) +50.0 kbp 0.4% 2.6% Sulfurihydrogenibium sp. YO3AOP1 +30.0 kbp 0.2% 0.7% Fusobacterium hwasookii (** 3 equal matches) +30.0 kbp 0.2% 0.0% Pseudomonas aeruginosa (** 2 equal matches) +30.0 kbp 0.2% 1.6% Persephonella marina (** 1 equal matches) +30.0 kbp 0.2% 0.4% Zymomonas mobilis +20.0 kbp 0.2% 1.1% Sulfurihydrogenibium yellowstonense (** 6 equal matches) +20.0 kbp 0.2% 0.5% Ruminiclostridium thermocellum (** 5 equal matches) +20.0 kbp 0.2% 0.1% Streptococcus parasanguinis (** 4 equal matches) +20.0 kbp 0.2% 0.8% Fusobacterium sp. HMSC064B11 (** 2 equal matches) +20.0 kbp 0.2% 0.4% Chlorobium phaeobacteroides (** 1 equal matches) +20.0 kbp 0.2% 0.7% Caldicellulosiruptor bescii +10.0 kbp 0.1% 0.0% Achromobacter xylosoxidans (** 53 equal matches) +10.0 kbp 0.1% 0.2% Geobacter sulfurreducens (** 17 equal matches) +10.0 kbp 0.1% 0.5% Fusobacterium sp. HMSC065F01 (** 15 equal matches) +10.0 kbp 0.1% 0.3% Nitrosomonas europaea (** 14 equal matches) +10.0 kbp 0.1% 0.5% Wolinella succinogenes (** 13 equal matches) +10.0 kbp 0.1% 0.5% Thermotoga neapolitana (** 12 equal matches) +10.0 kbp 0.1% 0.5% Thermus amyloliquefaciens (** 10 equal matches) +10.0 kbp 0.1% 0.1% Desulfovibrio desulfuricans (** 9 equal matches) +10.0 kbp 0.1% 0.4% Fusobacterium sp. CM22 (** 8 equal matches) +10.0 kbp 0.1% 0.2% Desulfovibrio piger (** 7 equal matches) +10.0 kbp 0.1% 0.5% Thermus kawarayensis (** 6 equal matches) +10.0 kbp 0.1% 0.5% Pyrococcus furiosus (** 5 equal matches) +10.0 kbp 0.1% 0.5% Aciduliprofundum boonei (** 4 equal matches) +10.0 kbp 0.1% 0.2% Desulfovibrio sp. A2 (** 3 equal matches) +10.0 kbp 0.1% 0.3% Desulfocurvus vexinensis (** 2 equal matches) +10.0 kbp 0.1% 0.0% Enterococcus faecalis + +22.1% (2.8 Mbp) of hashes have no assignment. + +``` + +What do the columns here mean? + +Why might some of things in a metagenome be unassigned? + +---- + +It is straightforward to build your own databases for use with +`search` and `lca gather`; this is of interest if you have dozens or +hundreds of sequencing data sets in your group. Ping us if you want us +to write that up. + +## Final thoughts on sourmash + +There are many tools like Kraken and Kaiju that can do taxonomic +classification of individual reads from metagenomes; these seem to +perform well (albeit with high false positive rates) in situations +where you don't necessarily have the genome sequences that are in the +metagenome. Sourmash, by contrast, can estimate which known genomes are +actually present, so that you can extract them and map/align to them. +It seems to have a very low false positive rate and is quite sensitive +to strains. + +Above, we've shown you a few things that you can use sourmash for. Here +is a (non-exclusive) list of other uses that we've been thinking about -- + +* detect contamination in sequencing data; + +* index and search private sequencing collections; + +* search all of SRA for overlaps in metagenomes + diff --git a/doc/tutorials.md b/doc/tutorials.md index a3a0277e38..4c0a9c0c97 100644 --- a/doc/tutorials.md +++ b/doc/tutorials.md @@ -1,27 +1,33 @@ # sourmash tutorials and notebooks -## The first two tutorials! +## The first three tutorials! -These tutorials are both command line tutorials that should work on Mac OS +These tutorials are command line tutorials that should work on Mac OS X and Linux. They require about 5 GB of disk space and 5 GB of RAM. * [The first sourmash tutorial - making signatures, comparing, and searching](tutorial-basic.md) * [Using sourmash LCA to do taxonomic classification](tutorials-lca.md) +* [Analyzing the genomic and taxonomic composition of an environmental genome using GTDB and sample-specific MAGs with sourmash](tutorial-lemonade.md) + ## Background and details These next three tutorials are all notebooks that you can view, run yourself, or run interactively online via the [binder](https://mybinder.org) service. -* [An introduction to k-mers for genome comparison and analysis](kmers-and-minhash.md) +* [An introduction to k-mers for genome comparison and analysis.](kmers-and-minhash.ipynb) + +* [Some sourmash command line examples!](sourmash-examples.ipynb) + +* [Working with private collections of signatures.](sourmash-collections.ipynb) -* [Some sourmash command line examples!](sourmash-examples.md) +## Advanced tutorials and more information -* [Working with private collections of signatures.](sourmash-collections.md) +For more information on analyzing sequencing data with sourmash, check out our [longer tutorial](tutorial-long.md). -## More information +Read [using `sourmash taxonomy` with the Life Identification Number (LIN) taxonomic framework](tutorial-lin-taxonomy.md) for some of our newer taxonomic features. If you are a Python programmer, you might also be interested in our [API examples](api-example.md) as well as a short guide to [Using the `LCA_Database` API.](using-LCA-database-API.ipynb) @@ -31,7 +37,7 @@ If you prefer R, we have [a short guide to using sourmash output with R](other-l If you're interested in customizing the output of `sourmash plot`, which produces comparison matrices and dendrograms, please see -[Building plots from `sourmash compare` output](plotting-compare.md). +[Building plots from `sourmash compare` output](plotting-compare.ipynb). ## Contents: @@ -43,6 +49,8 @@ tutorials-lca kmers-and-minhash sourmash-examples sourmash-collections +tutorial-long +tutorial-lemonade api-example using-LCA-database-API other-languages diff --git a/doc/using-LCA-database-API.ipynb b/doc/using-LCA-database-API.ipynb index ad8b8ac5ae..95c99e0f22 100644 --- a/doc/using-LCA-database-API.ipynb +++ b/doc/using-LCA-database-API.ipynb @@ -24,7 +24,7 @@ "### Running this notebook.\n", "\n", "You can run this notebook interactively via mybinder; click on this button:\n", - "[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/dib-lab/sourmash/latest?filepath=doc%2Fusing-LCA-database-API.ipynb)\n", + "[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/dib-lab/sourmash/latest?labpath=doc%2Fusing-LCA-database-API.ipynb)\n", "\n", "A rendered version of this notebook is available at [sourmash.readthedocs.io](https://sourmash.readthedocs.io) under \"Tutorials and notebooks\".\n", "\n", @@ -74,14 +74,14 @@ "text": [ "\r", "\u001b[K\r\n", - "== This is sourmash version 4.0.0a4.dev12+g31c5eda2. ==\r\n", + "== This is sourmash version 4.8.2. ==\r\n", "\r", "\u001b[K== Please cite Brown and Irber (2016), doi:10.21105/joss.00027. ==\r\n", "\r\n", "\r", "\u001b[Kcomputing signatures for files: genomes/akkermansia.fa, genomes/shew_os185.fa, genomes/shew_os223.fa\r\n", "\r", - "\u001b[KComputing a total of 1 signature(s).\r\n", + "\u001b[KComputing a total of 1 signature(s) for each input.\r\n", "\r", "\u001b[Kskipping genomes/akkermansia.fa - already done\r\n", "\r", @@ -144,9 +144,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "[(1.0,\n", - " SourmashSignature('CP001071.1 Akkermansia muciniphila ATCC BAA-835, complete genome', 6822e0b7),\n", - " None)]\n" + "[Result(score=1.0, signature=SourmashSignature('CP001071.1 Akkermansia muciniphila ATCC BAA-835, complete genome', 6822e0b7), location=None)]\n" ] } ], @@ -164,12 +162,8 @@ "name": "stdout", "output_type": "stream", "text": [ - "[(1.0,\n", - " SourmashSignature('NC_009665.1 Shewanella baltica OS185, complete genome', b47b13ef),\n", - " None),\n", - " (0.22846441947565543,\n", - " SourmashSignature('NC_011663.1 Shewanella baltica OS223, complete genome', ae6659f6),\n", - " None)]\n" + "[Result(score=1.0, signature=SourmashSignature('NC_009665.1 Shewanella baltica OS185, complete genome', b47b13ef), location=None),\n", + " Result(score=0.22846441947565543, signature=SourmashSignature('NC_011663.1 Shewanella baltica OS223, complete genome', ae6659f6), location=None)]\n" ] } ], @@ -186,14 +180,12 @@ "name": "stdout", "output_type": "stream", "text": [ - "[(1.0,\n", - " SourmashSignature('NC_011663.1 Shewanella baltica OS223, complete genome', ae6659f6),\n", - " None)]\n" + "Result(score=1.0, signature=SourmashSignature('NC_011663.1 Shewanella baltica OS223, complete genome', ae6659f6), location=None)\n" ] } ], "source": [ - "pprint(db.gather(sig3))" + "pprint(db.best_containment(sig3))" ] }, { @@ -251,7 +243,7 @@ } ], "source": [ - "pprint(db.ident_to_idx.keys())" + "pprint(db._ident_to_idx.keys())" ] }, { @@ -271,7 +263,7 @@ } ], "source": [ - "pprint(db.ident_to_name)" + "pprint(db._ident_to_name)" ] }, { @@ -285,7 +277,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The attribute `hashval_to_idx` contains a mapping from individual hash values to sets of `idx` indices.\n", + "The attribute `_hashval_to_idx` contains a mapping from individual hash values to sets of `idx` indices.\n", "\n", "See the method `_find_signatures()` for an example of how this is used in `search` and `gather`." ] @@ -304,7 +296,7 @@ } ], "source": [ - "print('{} hash values total in this database'.format(len(db.hashval_to_idx)))" + "print('{} hash values total in this database'.format(len(db._hashval_to_idx)))" ] }, { @@ -322,7 +314,7 @@ ], "source": [ "all_idx = set()\n", - "for idx_set in db.hashval_to_idx.values():\n", + "for idx_set in db._hashval_to_idx.values():\n", " all_idx.update(idx_set)\n", "print('belonging to signatures with idx {}'.format(all_idx))" ] @@ -333,7 +325,7 @@ "metadata": {}, "outputs": [], "source": [ - "first_three_hashvals = list(db.hashval_to_idx)[:3]" + "first_three_hashvals = list(db._hashval_to_idx)[:3]" ] }, { @@ -353,7 +345,7 @@ ], "source": [ "for hashval in first_three_hashvals:\n", - " print('hashval {} belongs to idxs {}'.format(hashval, db.hashval_to_idx[hashval]))" + " print('hashval {} belongs to idxs {}'.format(hashval, db._hashval_to_idx[hashval]))" ] }, { @@ -374,16 +366,16 @@ "source": [ "query_idx = 2\n", "hashval_set = set()\n", - "for hashval, idx_set in db.hashval_to_idx.items():\n", + "for hashval, idx_set in db._hashval_to_idx.items():\n", " if query_idx in idx_set:\n", " hashval_set.add(hashval)\n", " \n", "print('{} hashvals belong to query idx {}'.format(len(hashval_set), query_idx))\n", "\n", - "ident = db.idx_to_ident[query_idx]\n", + "ident = db._idx_to_ident[query_idx]\n", "print('query idx {} matches to ident {}'.format(query_idx, ident))\n", "\n", - "name = db.ident_to_name[ident]\n", + "name = db._ident_to_name[ident]\n", "print('query idx {} matches to name {}'.format(query_idx, name))" ] }, @@ -400,7 +392,7 @@ "metadata": {}, "outputs": [], "source": [ - "from sourmash.lca import LineagePair" + "from sourmash.lca.lca_utils import LineagePair" ] }, { @@ -455,13 +447,13 @@ "source": [ "# by default, the identifier is the signature name --\n", "ident = sig1.name\n", - "idx = db.ident_to_idx[ident]\n", + "idx = db._ident_to_idx[ident]\n", "print(\"ident '{}' has idx {}\".format(ident, idx))\n", "\n", - "lid = db.idx_to_lid[idx]\n", + "lid = db._idx_to_lid[idx]\n", "print(\"lid for idx {} is {}\".format(idx, lid))\n", "\n", - "lineage = db.lid_to_lineage[lid]\n", + "lineage = db._lid_to_lineage[lid]\n", "display = sourmash.lca.display_lineage(lineage)\n", "print(\"lineage for lid {} is {}\".format(lid, display))" ] @@ -725,8 +717,8 @@ "name": "stderr", "output_type": "stream", "text": [ - "/Users/t/miniconda3/envs/py37/lib/python3.7/site-packages/ipykernel_launcher.py:1: DeprecatedWarning: get_mins is deprecated as of 3.5 and will be removed in 5.0. Use .hashes property instead.\n", - " \"\"\"Entry point for launching an IPython kernel.\n" + "/var/folders/6s/_f373w1d6hdfjc2kjstq97s80000gp/T/ipykernel_3384/490137846.py:1: DeprecatedWarning: get_mins is deprecated as of 3.5 and will be removed in 5.0. Use .hashes property instead.\n", + " assignments = sourmash.lca.gather_assignments(sig2.minhash.get_mins(), [db])\n" ] } ], @@ -834,9 +826,9 @@ ], "metadata": { "kernelspec": { - "display_name": "Python (myenv)", + "display_name": "smash-notebooks", "language": "python", - "name": "myenv" + "name": "smash-notebooks" }, "language_info": { "codemirror_mode": { @@ -848,7 +840,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.6" + "version": "3.11.3" } }, "nbformat": 4, diff --git a/doc/using-sourmash-a-guide.md b/doc/using-sourmash-a-guide.md index bde3827182..29ccc52ec1 100644 --- a/doc/using-sourmash-a-guide.md +++ b/doc/using-sourmash-a-guide.md @@ -127,6 +127,17 @@ reads; the second takes all the trimmed read files, subsamples k-mers from them at 1000:1, and outputs a single merged signature named 'SOMENAME' into the file `SOMENAME-reads.sig`. +### Calculating a combined signature for multiple read files + +``` +sourmash sketch dna -p scaled=1000,k=21,k=31,k=51 sample_*.fq.gz \ + --name "combined sketch for sample" -o sample.zip +``` + +This will build combined sketches of all `*.fq.gz` files +in the directory for three ksizes, k=21, k=31, and k=51. The three sketches +will be named `combined sketch for sample` and be saved to `sample.zip`. + ### Creating signatures for individual genome files: ``` diff --git a/flake.lock b/flake.lock index 8e41c96a5e..f9fc0a31af 100644 --- a/flake.lock +++ b/flake.lock @@ -1,47 +1,5 @@ { "nodes": { - "flake-utils": { - "locked": { - "lastModified": 1642700792, - "narHash": "sha256-XqHrk7hFb+zBvRg6Ghl+AZDq03ov6OshJLiSWOoX5es=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "846b2ae0fc4cc943637d3d1def4454213e203cba", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "mach-nix": { - "inputs": { - "flake-utils": [ - "utils" - ], - "nixpkgs": [ - "nixpkgs" - ], - "pypi-deps-db": [ - "pypi-deps-db" - ] - }, - "locked": { - "lastModified": 1643953409, - "narHash": "sha256-CJDg/RpZdUVyI3QIAXUqIoYDl7VkxFtNE4JWih0ucKc=", - "owner": "DavHau", - "repo": "mach-nix", - "rev": "fe5255e6fd8df57e9507b7af82fc59dda9e9ff2b", - "type": "github" - }, - "original": { - "owner": "DavHau", - "ref": "3.4.0", - "repo": "mach-nix", - "type": "github" - } - }, "naersk": { "inputs": { "nixpkgs": [ @@ -49,11 +7,11 @@ ] }, "locked": { - "lastModified": 1648544490, - "narHash": "sha256-EoBDcccV70tfz2LAs5lK0BjC7en5mzUVlgLsd5E6DW4=", + "lastModified": 1688534083, + "narHash": "sha256-/bI5vsioXscQTsx+Hk9X5HfweeNZz/6kVKsbdqfwW7g=", "owner": "nix-community", "repo": "naersk", - "rev": "e30ef9a5ce9b3de8bb438f15829c50f9525ca730", + "rev": "abca1fb7a6cfdd355231fc220c3d0302dbb4369a", "type": "github" }, "original": { @@ -64,11 +22,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1648219316, - "narHash": "sha256-Ctij+dOi0ZZIfX5eMhgwugfvB+WZSrvVNAyAuANOsnQ=", + "lastModified": 1689449371, + "narHash": "sha256-sK3Oi8uEFrFPL83wKPV6w0+96NrmwqIpw9YFffMifVg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "30d3d79b7d3607d56546dd2a6b49e156ba0ec634", + "rev": "29bcead8405cfe4c00085843eb372cc43837bb9d", "type": "github" }, "original": { @@ -78,64 +36,10 @@ "type": "github" } }, - "nixpkgs_2": { - "locked": { - "lastModified": 1643805626, - "narHash": "sha256-AXLDVMG+UaAGsGSpOtQHPIKB+IZ0KSd9WS77aanGzgc=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "554d2d8aa25b6e583575459c297ec23750adb6cb", - "type": "github" - }, - "original": { - "id": "nixpkgs", - "ref": "nixos-unstable", - "type": "indirect" - } - }, - "pypi-deps-db": { - "inputs": { - "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs_2", - "pypi-deps-db": "pypi-deps-db_2" - }, - "locked": { - "lastModified": 1643953409, - "narHash": "sha256-CJDg/RpZdUVyI3QIAXUqIoYDl7VkxFtNE4JWih0ucKc=", - "owner": "DavHau", - "repo": "mach-nix", - "rev": "fe5255e6fd8df57e9507b7af82fc59dda9e9ff2b", - "type": "github" - }, - "original": { - "owner": "DavHau", - "ref": "3.4.0", - "repo": "mach-nix", - "type": "github" - } - }, - "pypi-deps-db_2": { - "flake": false, - "locked": { - "lastModified": 1643877077, - "narHash": "sha256-jv8pIvRFTP919GybOxXE5TfOkrjTbdo9QiCO1TD3ZaY=", - "owner": "DavHau", - "repo": "pypi-deps-db", - "rev": "da53397f0b782b0b18deb72ef8e0fb5aa7c98aa3", - "type": "github" - }, - "original": { - "owner": "DavHau", - "repo": "pypi-deps-db", - "type": "github" - } - }, "root": { "inputs": { - "mach-nix": "mach-nix", "naersk": "naersk", "nixpkgs": "nixpkgs", - "pypi-deps-db": "pypi-deps-db", "rust-overlay": "rust-overlay", "utils": "utils" } @@ -150,11 +54,11 @@ ] }, "locked": { - "lastModified": 1648866882, - "narHash": "sha256-yMs/RKA9pX47a03zupmQp8/RLRmKzdSDk+h5Yt7K9xU=", + "lastModified": 1689475081, + "narHash": "sha256-lAyG+KKKjOAG1YxYnji1g1pV39WxzQQBHI3ZwoRzweM=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "7c90e17cd7c0b9e81d5b23f78b482088ac9961d1", + "rev": "6e28f20574595b01e14f2bbb57d62b84393fdcc1", "type": "github" }, "original": { @@ -163,13 +67,31 @@ "type": "github" } }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, "utils": { + "inputs": { + "systems": "systems" + }, "locked": { - "lastModified": 1648297722, - "narHash": "sha256-W+qlPsiZd8F3XkzXOzAoR+mpFqzm3ekQkJNa+PIh1BQ=", + "lastModified": 1689068808, + "narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=", "owner": "numtide", "repo": "flake-utils", - "rev": "0f8662f1319ad6abf89b3380dd2722369fc51ade", + "rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index ba080af204..21bee98f82 100644 --- a/flake.nix +++ b/flake.nix @@ -16,23 +16,11 @@ url = "github:nix-community/naersk"; inputs = { nixpkgs.follows = "nixpkgs"; - flake-utils.follows = "utils"; }; }; - - mach-nix = { - url = "github:DavHau/mach-nix/3.4.0"; - inputs.nixpkgs.follows = "nixpkgs"; - inputs.flake-utils.follows = "utils"; - inputs.pypi-deps-db.follows = "pypi-deps-db"; - }; - - pypi-deps-db = { - url = "github:DavHau/mach-nix/3.4.0"; - }; }; - outputs = { self, nixpkgs, naersk, rust-overlay, mach-nix, pypi-deps-db, utils }: + outputs = { self, nixpkgs, naersk, rust-overlay, utils }: utils.lib.eachDefaultSystem (system: let overlays = [ (import rust-overlay) ]; @@ -49,34 +37,43 @@ rustc = rustVersion; }; naersk-lib = naersk.lib."${system}".override { - cargo = rustPlatform.rust.cargo; - rustc = rustPlatform.rust.rustc; + cargo = rustVersion; + rustc = rustVersion; }; - python = "python39"; - mach-nix-wrapper = import mach-nix { inherit pkgs python; }; + python = pkgs.python311Packages; + in with pkgs; { packages = { + lib = naersk-lib.buildPackage { pname = "libsourmash"; root = ./.; copyLibs = true; }; - sourmash = mach-nix-wrapper.buildPythonPackage { + + sourmash = python.buildPythonPackage rec { + pname = "sourmash"; + version = "4.8.3"; + format = "pyproject"; + src = ./.; - version = "4.3.0"; - requirementsExtra = '' - setuptools >= 48, <60 - milksnake - setuptools_scm[toml] >= 4, <6 - ''; - SETUPTOOLS_SCM_PRETEND_VERSION = "4.3.0"; + + cargoDeps = rustPlatform.importCargoLock { + lockFile = ./Cargo.lock; + }; + + nativeBuildInputs = with rustPlatform; [ cargoSetupHook maturinBuildHook ]; + + buildInputs = lib.optionals stdenv.isDarwin [ libiconv ]; + propagatedBuildInputs = with python; [ cffi deprecation cachetools bitstring numpy scipy matplotlib screed ]; + DYLD_LIBRARY_PATH = "${self.packages.${system}.lib}/lib"; - NO_BUILD = "1"; }; + docker = let bin = self.defaultPackage.${system}; @@ -101,24 +98,26 @@ ]; buildInputs = [ - rustPlatform.rust.cargo + rustVersion openssl pkgconfig git stdenv.cc.cc.lib - (python310.withPackages (ps: with ps; [ virtualenv tox setuptools ])) - (python39.withPackages (ps: with ps; [ virtualenv setuptools ])) - (python38.withPackages (ps: with ps; [ virtualenv setuptools ])) + (python311.withPackages (ps: with ps; [ virtualenv tox cffi ])) + (python310.withPackages (ps: with ps; [ virtualenv ])) + (python39.withPackages (ps: with ps; [ virtualenv ])) + (python38.withPackages (ps: with ps; [ virtualenv ])) rust-cbindgen + maturin wasmtime wasm-pack - nodejs-16_x + nodejs_20 - py-spy - heaptrack + #py-spy + #heaptrack cargo-watch cargo-limit cargo-outdated diff --git a/include/sourmash.h b/include/sourmash.h index 1f116e681b..6fa7854880 100644 --- a/include/sourmash.h +++ b/include/sourmash.h @@ -29,6 +29,7 @@ enum SourmashErrorCode { SOURMASH_ERROR_CODE_MISMATCH_SIGNATURE_TYPE = 105, SOURMASH_ERROR_CODE_NON_EMPTY_MIN_HASH = 106, SOURMASH_ERROR_CODE_MISMATCH_NUM = 107, + SOURMASH_ERROR_CODE_NEEDS_ABUNDANCE_TRACKING = 108, SOURMASH_ERROR_CODE_INVALID_DNA = 1101, SOURMASH_ERROR_CODE_INVALID_PROT = 1102, SOURMASH_ERROR_CODE_INVALID_CODON_LENGTH = 1103, diff --git a/paper.bib b/paper.bib index 5df6cafd4a..03f1ffd46d 100644 --- a/paper.bib +++ b/paper.bib @@ -1,4 +1,4 @@ -@article{ondov2015fast, +@article{Ondov:2015, title={Fast genome and metagenome distance estimation using MinHash}, author={Ondov, Brian D and Treangen, Todd J and Mallonee, Adam B and Bergman, Nicholas H and Koren, Sergey and Phillippy, Adam M}, journal={bioRxiv}, @@ -8,3 +8,62 @@ @article{ondov2015fast doi={10.1101/029827}, url={https://doi.org/10.1101/029827} } + +@article{Brown:2016, + doi = {10.21105/joss.00027}, + url = {https://doi.org/10.21105/joss.00027}, + year = {2016}, + publisher = {The Open Journal}, + volume = {1}, + number = {5}, + pages = {27}, + author = {C. Titus Brown and Luiz Irber}, + title = {sourmash: a library for MinHash sketching of DNA}, + journal = {Journal of Open Source Software} +} + +@article{Pierce:2019, + doi = {10.12688/f1000research.19675.1}, + url = {https://doi.org/10.12688/f1000research.19675.1}, + year = {2019}, + month = jul, + publisher = {F1000 Research Ltd}, + volume = {8}, + pages = {1006}, + author = {N. Tessa Pierce and Luiz Irber and Taylor Reiter and Phillip Brooks and C. Titus Brown}, + title = {Large-scale sequence comparisons with sourmash}, + journal = {F1000Research} +} +@article{gather, + title={Lightweight compositional analysis of metagenomes with FracMinHash and minimum metagenome covers}, + author={Irber, Luiz Carlos and Brooks, Phillip T and Reiter, Taylor E and Pierce-Ward, N Tessa and Hera, Mahmudur Rahman and Koslicki, David and Brown, C Titus}, + journal={bioRxiv}, + year={2022}, + publisher={Cold Spring Harbor Laboratory} +} + +@article{branchwater, + title={Sourmash Branchwater Enables Lightweight Petabyte-Scale Sequence Search}, + author={Irber, Luiz Carlos and Pierce-Ward, N Tessa and Brown, C Titus}, + journal={bioRxiv}, + year={2022}, + publisher={Cold Spring Harbor Laboratory} +} + +@article{koslicki2019improving, + title={Improving minhash via the containment index with applications to metagenomic analysis}, + author={Koslicki, David and Zabeti, Hooman}, + journal={Applied Mathematics and Computation}, + volume={354}, + pages={206--215}, + year={2019}, + publisher={Elsevier} +} + +@article{hera2022debiasing, + title={Debiasing FracMinHash and deriving confidence intervals for mutation rates across a wide range of evolutionary distances}, + author={Hera, Mahmudur Rahman and Pierce-Ward, N Tessa and Koslicki, David}, + journal={bioRxiv}, + year={2022}, + publisher={Cold Spring Harbor Laboratory} +} diff --git a/paper.md b/paper.md index 088e519d24..2a04b4575a 100644 --- a/paper.md +++ b/paper.md @@ -1,32 +1,164 @@ --- -title: 'sourmash: a library for MinHash sketching of DNA' +title: 'sourmash: a tool to quickly search, compare, and analyze genomic and metagenomic data sets' tags: + - FracMinHash - MinHash - k-mers - Python + - Rust authors: - - name: C. Titus Brown - orcid: 0000-0001-6001-2677 - affiliation: University of California, Davis - name: Luiz Irber orcid: 0000-0003-4371-9659 - affiliation: University of California, Davis -date: 13 Sep 2016 + equal-contrib: true + affiliation: 1 + - name: N. Tessa Pierce-Ward + orcid: 0000-0002-2942-5331 + equal-contrib: true + affiliation: 1 + - name: Mohamed Abuelanin + orcid: 0000-0002-3419-4785 + affiliation: 1 + - name: Harriet Alexander + orcid: 0000-0003-1308-8008 + affiliation: 2 + - name: Abhishek Anant + orcid: 0000-0002-5751-2010 + affiliation: 9 + - name: Keya Barve + orcid: 0000-0003-3241-2117 + affiliation: 1 + - name: Colton Baumler + orcid: 0000-0002-5926-7792 + affiliation: 1 + - name: Olga Botvinnik + orcid: 0000-0003-4412-7970 + affiliation: 3 + - name: Phillip Brooks + orcid: 0000-0003-3987-244X + affiliation: 1 + - name: Daniel Dsouza + orcid: 0000-0001-7843-8596 + affiliation: 9 + - name: Laurent Gautier + orcid: 0000-0003-0638-3391 + affiliation: 9 + - name: Mahmudur Rahman Hera + orcid: 0000-0002-5992-9012 + affiliation: 4 + - name: Hannah Eve Houts + orcid: 0000-0002-7954-4793 + affiliation: 1 + - name: Lisa K. Johnson + orcid: 0000-0002-3600-7218 + affiliation: 1 + - name: Fabian Klötzl + orcid: 0000-0002-6930-0592 + affiliation: 5 + - name: David Koslicki + orcid: 0000-0002-0640-954X + affiliation: 4 + - name: Marisa Lim + orcid: 0000-0003-2097-8818 + affiliation: 1 + - name: Ricky Lim + orcid: 0000-0003-1313-7076 + affiliation: 9 + - name: Ivan Ogasawara + orcid: 0000-0001-5049-4289 + affiliation: 9 + - name: Taylor Reiter + orcid: 0000-0002-7388-421X + affiliation: 1 + - name: Camille Scott + orcid: 0000-0001-8822-8779 + affiliation: 1 + - name: Andreas Sjödin + orcid: 0000-0001-5350-4219 + affiliation: 6 + - name: Daniel Standage + orcid: 0000-0003-0342-8531 + affiliation: 7 + - name: S. Joshua Swamidass + orcid: 0000-0003-2191-0778 + affiliation: 8 + - name: Connor Tiffany + orcid: 0000-0001-8188-7720 + affiliation: 9 + - name: Pranathi Vemuri + orcid: 0000-0002-5748-9594 + affiliation: 3 + - name: Erik Young + orcid: 0000-0002-9195-9801 + affiliation: 1 + - name: C. Titus Brown + orcid: 0000-0001-6001-2677 + corresponding: true + affiliation: 1 +affiliations: + - name: University of California, Davis + index: 1 + - name: Woods Hole Oceanographic Institution + index: 2 + - name: Chan-Zuckerberg Biohub + index: 3 + - name: Pennsylvania State University + index: 4 + - name: MPI for Evolutionary Biology + index: 5 + - name: Swedish Defence Research Agency (FOI) + index: 6 + - name: National Bioforensic Analysis Center + index: 7 + - name: Washington University in St Louis + index: 8 + - name: No affiliation + index: 9 + +date: 27 Mar 2023 bibliography: paper.bib --- # Summary -sourmash is a toolbox for creating, comparing, and manipulating MinHash -sketches of genomic data. +sourmash is a command line tool and Python library for sketching +collections of DNA, RNA, and amino acid k-mers for biological sequence +search, comparison, and analysis [@Pierce:2019]. sourmash's FracMinHash sketching supports fast and accurate sequence comparisons between datasets of different sizes [@gather], including petabase-scale database search [@branchwater]. From release 4.x, sourmash is built on top of Rust and provides an experimental Rust interface. + +FracMinHash sketching is a lossy compression approach that represents +data sets using a "fractional" sketch containing $1/S$ of the original +k-mers. Like other sequence sketching techniques (e.g. MinHash, [@Ondov:2015]), FracMinHash provides a lightweight way to store representations of large DNA or RNA sequence collections for comparison and search. Sketches can be used to identify samples, find similar samples, identify data sets with shared sequences, and build phylogenetic trees. FracMinHash sketching supports estimation of overlap, bidirectional containment, and Jaccard similarity between data sets and is accurate even for data sets of very different sizes. + +Since sourmash v1 was released in 2016 [@Brown:2016], sourmash has expanded +to support new database types and many more command line functions. +In particular, sourmash now has robust support for both Jaccard similarity +and containment calculations, which enables analysis and comparison of data sets +of different sizes, including large metagenomic samples. As of v4.4, +sourmash can convert these to estimated Average Nucleotide Identity (ANI) +values, which can provide improved biological context to sketch comparisons [@hera2022debiasing]. + +# Statement of Need + +Large collections of genomes, transcriptomes, and raw sequencing data +sets are readily available in biology, and the field needs lightweight +computational methods for searching and summarizing the content of +both public and private collections. sourmash provides a flexible set +of programmatic functionality for this purpose, together with a robust +and well-tested command-line interface. It has been used in well over 200 +publications (based on citations of @Brown:2016 and @Pierce:2019) and it continues +to expand in functionality. + +# Acknowledgements -MinHash sketches provide a lightweight way to store "signatures" of -large DNA or RNA sequence collections, and then compare or search them -using a Jaccard index. MinHash sketches can be used to identify samples, -find similar samples, identify data sets with shared sequences, and -build phylogenetic trees [@ondov2015fast]. +This work is funded in part by the Gordon and Betty Moore Foundation’s +Data-Driven Discovery Initiative [GBMF4551 to CTB]. -sourmash provides a command line script, a Python library, and a CPython -module for MinHash sketches. +Notice: This manuscript has been authored by BNBI under Contract +No. HSHQDC-15-C-00064 with the DHS. The US Government retains +and the publisher, by accepting the article for publication, acknowledges +that the USG retains a non-exclusive, paid-up, irrevocable, world-wide +license to publish or reproduce the published form of this manuscript, +or allow others to do so, for USG purposes. Views and conclusions +contained herein are those of the authors and should not be interpreted +to represent policies, expressed or implied, of the DHS. # References diff --git a/pyproject.toml b/pyproject.toml index abe31ec81e..48d0152679 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,19 +1,155 @@ [build-system] requires = [ - "setuptools >= 48, <60", - "setuptools_scm[toml] >= 4, <6", - "setuptools_scm_git_archive", - "milksnake", - "wheel >= 0.29.0", + "maturin>=1,<2", + "cffi", ] -build-backend = 'setuptools.build_meta' +build-backend = 'maturin' -[tool.setuptools_scm] -write_to = "src/sourmash/version.py" -git_describe_command = "git describe --dirty --tags --long --match v* --first-parent" +[project] +name = "sourmash" +description = "tools for comparing biological sequences with k-mer sketches" +readme = "README.md" +version = "4.8.4-dev" + +authors = [ + { name="Luiz Irber", orcid="0000-0003-4371-9659" }, + { name="N. Tessa Pierce-Ward", orcid="0000-0002-2942-5331" }, + { name="Mohamed Abuelanin", orcid="0000-0002-3419-4785" }, + { name="Harriet Alexander", orcid="0000-0003-1308-8008" }, + { name="Abhishek Anant", orcid="0000-0002-5751-2010" }, + { name="Keya Barve", orcid="0000-0003-3241-2117" }, + { name="Colton Baumler", orcid="0000-0002-5926-7792" }, + { name="Olga Botvinnik", orcid="0000-0003-4412-7970" }, + { name="Phillip Brooks", orcid="0000-0003-3987-244X" }, + { name="Peter Cock", orcid="0000-0001-9513-9993" }, + { name="Daniel Dsouza", orcid="0000-0001-7843-8596" }, + { name="Laurent Gautier", orcid="0000-0003-0638-3391" }, + { name="Tim Head", orcid="0000-0003-0931-3698" }, + { name="Mahmudur Rahman Hera", orcid="0000-0002-5992-9012" }, + { name="Hannah Eve Houts", orcid="0000-0002-7954-4793" }, + { name="Lisa K. Johnson", orcid="0000-0002-3600-7218" }, + { name="Fabian Klötzl", orcid="0000-0002-6930-0592" }, + { name="David Koslicki", orcid="0000-0002-0640-954X" }, + { name="Katrin Leinweber", orcid="0000-0001-5135-5758" }, + { name="Marisa Lim", orcid="0000-0003-2097-8818" }, + { name="Ricky Lim", orcid="0000-0003-1313-7076" }, + { name="Ivan Ogasawara", orcid="0000-0001-5049-4289" }, + { name="Taylor Reiter", orcid="0000-0002-7388-421X" }, + { name="Camille Scott", orcid="0000-0001-8822-8779" }, + { name="Andreas Sjödin", orcid="0000-0001-5350-4219" }, + { name="Connor T. Skennerton", orcid="0000-0003-1320-4873" }, + { name="Jason Stajich", orcid="0000-0002-7591-0020" }, + { name="Daniel Standage", orcid="0000-0003-0342-8531" }, + { name="S. Joshua Swamidass", orcid="0000-0003-2191-0778" }, + { name="Connor Tiffany", orcid="0000-0001-8188-7720" }, + { name="Pranathi Vemuri", orcid="0000-0002-5748-9594"}, + { name="Erik Young", orcid="0000-0002-9195-9801" }, + { name="Nick H", orcid="0000-0002-1685-302X" }, + { name="C. Titus Brown", orcid="0000-0001-6001-2677" }, +] + +maintainers = [ + { name="Luiz Irber", email="luiz@sourmash.bio", orcid="0000-0003-4371-9659" }, + { name="C. Titus Brown", email="titus@idyll.org", orcid="0000-0001-6001-2677" }, +] + +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Environment :: MacOS X", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: BSD License", + "Natural Language :: English", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS :: MacOS X", + "Programming Language :: Rust", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Topic :: Scientific/Engineering :: Bio-Informatics", +] + +dependencies = [ + "screed>=1.1.2,<2", + "cffi>=1.14.0", + "numpy", + "matplotlib", + "scipy", + "deprecation>=2.0.6", + "cachetools>=4,<6", + "bitstring>=3.1.9,<5", + "importlib_metadata>=3.6;python_version<'3.10'" +] + +requires-python = ">=3.8" + +[metadata] +license = { text = "BSD 3-Clause License" } + +[project.urls] +"Homepage" = "https://sourmash.bio/" +"Documentation" = "https://sourmash.readthedocs.io" +"CI" = "https://github.com/sourmash-bio/sourmash/actions" +"Source" = "https://github.com/sourmash-bio/sourmash" +"Tracker" = "https://github.com/sourmash-bio/sourmash/issues" + +[project.scripts] +"sourmash" = "sourmash.__main__:main" + +[project.optional-dependencies] +test = [ + "pytest>=6.2.4,<7.5.0", + "pytest-cov>=2.12,<5.0", + "pytest-xdist", + "pyyaml>=6,<7", + "recommonmark", + "hypothesis", + "build", +] +demo = [ + "jupyter", + "jupyter_client", + "ipython", +] +doc = [ + "sphinx>=4.4.0,<8", + "myst-parser==2.0.0", + "Jinja2==3.1.2", + "alabaster", + "sphinxcontrib-napoleon", + "nbsphinx", + "ipython", + "docutils>=0.17.1,<0.21", +] +storage = [ + "ipfshttpclient>=0.4.13", + "redis", +] +# hmm, I think this is a hack, but it works... +# https://github.com/pypa/pip/issues/10393#issuecomment-941885429 +all = ["sourmash[test,demo,doc,storage]"] + +[tool.maturin] +python-source = "src" +manifest-path = "src/core/Cargo.toml" +bindings = "cffi" +include = [ + { path = "include/sourmash.h", format = ["sdist","wheel"] }, + { path = "requirements.txt", format = ["sdist"] }, + { path = "Cargo.*", format = ["sdist"] }, + { path = "tests/**/*", format = ["sdist"] }, + { path = "CITATION.cff", format = ["sdist", "wheel"] }, + { path = "LICENSE", format = ["sdist"] }, +] +exclude = [ + { path = "**/__pycache__/*", format = ["sdist", "wheel"] }, +] +features = ["maturin"] +locked = true +module-name = "sourmash._lowlevel" [tool.isort] -known_third_party = ["deprecation", "hypothesis", "mmh3", "numpy", "pkg_resources", "pytest", "screed", "setuptools", "sourmash_tst_utils"] +known_third_party = ["deprecation", "hypothesis", "mmh3", "numpy", "pytest", "screed", "sourmash_tst_utils"] multi_line_output = 3 include_trailing_comma = true force_grid_wrap = 0 @@ -26,3 +162,18 @@ skip = "*-win32 *-manylinux_i686 *-musllinux_ppc64le *-musllinux_s390x" before-build = "source .ci/install_cargo.sh" environment = { PATH="$HOME/.cargo/bin:$PATH" } build-verbosity = 3 + +[tool.pytest.ini_options] +addopts = "--doctest-glob='doc/*.md' -n4" +norecursedirs = [ + "utils", + "build", + "buildenv", + ".tox", + ".asv", + ".eggs", +] +testpaths = [ + "tests", + "doc", +] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index f364705b2e..0000000000 --- a/setup.cfg +++ /dev/null @@ -1,102 +0,0 @@ -[metadata] -name = sourmash -description = tools for comparing DNA sequences with MinHash sketches -long_description = file: README.md -long_description_content_type = text/markdown; charset=UTF-8 -url = https://github.com/dib-lab/sourmash -author = Luiz Irber, Harriet Alexander, Olga Botvinnik, Phillip Brooks, Laurent Gautier, Tim Head, Lisa K. Johnson, Fabian Klötzl, Katrin Leinweber, Ivan Ogasawara, N. Tessa Pierce, Taylor Reiter, Andreas Sjödin, Connor T. Skennerton, Daniel Standage, S. Joshua Swamidass, Connor Tiffany, Erik Young, C. Titus Brown -author_email = titus@idyll.org -maintainer = Luiz Irber, C. Titus Brown -maintainer_email = lcirberjr@ucdavis.edu, titus@idyll.org -license = BSD 3-clause -license_file = LICENSE -classifiers = - Development Status :: 5 - Production/Stable - Environment :: Console - Environment :: MacOS X - Intended Audience :: Science/Research - License :: OSI Approved :: BSD License - Natural Language :: English - Operating System :: POSIX :: Linux - Operating System :: MacOS :: MacOS X - Programming Language :: Rust - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Topic :: Scientific/Engineering :: Bio-Informatics -project_urls = - Documentation = https://sourmash.readthedocs.io - CI = https://github.com/dib-lab/sourmash/actions - Source = https://github.com/dib-lab/sourmash - Tracker = https://github.com/dib-lab/sourmash/issues - -[options] -zip_safe = False -packages = find: -platforms = any -include_package_data = True -install_requires = - screed>=1.0.5 - cffi>=1.14.0 - numpy - matplotlib - scipy - deprecation>=2.0.6 - cachetools>=4,<6 - bitstring>=3.1.9,<4 -python_requires = >=3.8 - -[bdist_wheel] -universal = 1 - -[options.packages.find] -where = src - -# for explanation of %(extra)s syntax see: -# https://github.com/pypa/setuptools/issues/1260#issuecomment-438187625 -# this syntax may change in the future -[options.extras_require] -test = - pytest>=6.2.4,<7.2.0 - pytest-cov>=2.12,<4.0 - recommonmark - hypothesis -demo = - jupyter - jupyter_client - ipython -doc = - sphinx>=4.4.0,<5 - myst-parser==0.17.2 - Jinja2==3.1.2 - alabaster - sphinxcontrib-napoleon - nbsphinx - ipython - docutils>=0.17.1,<0.19.0 -storage = - ipfshttpclient>=0.4.13 - redis -all = - %(test)s - %(demo)s - %(doc)s - %(storage)s - -[options.entry_points] -console_scripts = - sourmash = sourmash.__main__:main - -[tool:pytest] -addopts = - --doctest-glob='doc/*.md' -norecursedirs = - utils - build - buildenv - .tox - .asv - .eggs -testpaths = - tests - doc diff --git a/setup.py b/setup.py deleted file mode 100644 index e31e09fd1f..0000000000 --- a/setup.py +++ /dev/null @@ -1,69 +0,0 @@ -import os -import sys - -from setuptools import setup - - -DEBUG_BUILD = os.environ.get("SOURMASH_DEBUG") == "1" -NO_BUILD = os.environ.get("NO_BUILD") == "1" - - -def find_dylib_no_build(name, paths): - to_find = None - if sys.platform == 'darwin': - to_find = f'lib{name}.dylib' - elif sys.platform == 'win32': - to_find = f'{name}.dll' - else: - to_find = f'lib{name}.so' - - for path in paths.split(":"): - for filename in os.listdir(path): - if filename == to_find: - return os.path.join(path, filename) - - raise LookupError('dylib %r not found' % name) - - -def find_dylib(build, target): - cargo_target = os.environ.get("CARGO_BUILD_TARGET") - if cargo_target: - in_path = "target/%s/%s" % (cargo_target, target) - else: - in_path = "target/%s" % target - return build.find_dylib("sourmash", in_path=in_path) - - -def build_native(spec): - cmd = ["cargo", "build", - "--manifest-path", "src/core/Cargo.toml", - # "--features", "parallel", - "--lib"] - - target = "debug" - if not DEBUG_BUILD: - cmd.append("--release") - target = "release" - - if NO_BUILD: - dylib = lambda: find_dylib("sourmash", os.environ["DYLD_LIBRARY_PATH"]) - header_filename = lambda: "include/sourmash.h" - else: - build = spec.add_external_build(cmd=cmd, path=".") - dylib = lambda: find_dylib(build, target) - header_filename=lambda: build.find_header("sourmash.h", in_path="include") - - rtld_flags = ["NOW"] - if sys.platform == "darwin": - rtld_flags.append("NODELETE") - spec.add_cffi_module( - module_path="sourmash._lowlevel", - dylib=dylib, - header_filename=header_filename, - rtld_flags=rtld_flags, - ) - -setup( - milksnake_tasks=[build_native], - package_dir={"": "src"}, -) diff --git a/src/core/Cargo.toml b/src/core/Cargo.toml index 424ec9764a..a5c68435ab 100644 --- a/src/core/Cargo.toml +++ b/src/core/Cargo.toml @@ -3,14 +3,15 @@ name = "sourmash" version = "0.11.0" authors = ["Luiz Irber "] description = "MinHash sketches for genomic data" -repository = "https://github.com/dib-lab/sourmash" +repository = "https://github.com/sourmash-bio/sourmash" keywords = ["minhash", "bioinformatics"] categories = ["science", "algorithms", "data-structures"] license = "BSD-3-Clause" -edition = "2018" +edition = "2021" readme = "README.md" autoexamples = false autobins = false +rust-version = "1.64.0" [lib] name = "sourmash" @@ -20,43 +21,44 @@ bench = false [features] from-finch = ["finch"] parallel = ["rayon"] +maturin = [] [dependencies] az = "1.0.0" bytecount = "0.6.0" byteorder = "1.4.3" cfg-if = "1.0" -counter = "0.5.4" -finch = { version = "0.4.1", optional = true } +counter = "0.5.7" +finch = { version = "0.5.0", optional = true } fixedbitset = "0.4.0" +getrandom = { version = "0.2", features = ["js"] } getset = "0.1.1" -log = "0.4.8" +log = "0.4.20" md5 = "0.7.0" murmurhash3 = "0.0.5" niffler = { version = "2.3.1", default-features = false, features = [ "gz" ] } nohash-hasher = "0.2.0" num-iter = "0.1.43" -once_cell = "1.3.1" -rayon = { version = "1.5.2", optional = true } -serde = { version = "1.0.137", features = ["derive"] } -serde_json = "1.0.80" +once_cell = "1.18.0" # once_cell 1.14+ requires Rust 1.56+ +rayon = { version = "1.7.0", optional = true } +serde = { version = "1.0.168", features = ["derive"] } +serde_json = "1.0.104" primal-check = "0.3.1" thiserror = "1.0" -typed-builder = "0.10.0" +typed-builder = "0.14.0" twox-hash = "1.6.0" vec-collections = "0.3.4" -piz = "0.4.0" -memmap2 = "0.5.0" -ouroboros = "0.15.0" +piz = "0.4.0" # piz 0.5.1 requires Rust 1.63+ +memmap2 = "0.7.1" +ouroboros = "0.17.2" [dev-dependencies] assert_matches = "1.3.0" -criterion = "0.3.2" -needletail = { version = "0.4.1", default-features = false } -proptest = { version = "1.0.0", default-features = false, features = ["std"]} +criterion = "0.5.1" +needletail = { version = "0.5.1", default-features = false } +proptest = { version = "1.2.0", default-features = false, features = ["std"]} rand = "0.8.2" -getrandom = { version = "0.2", features = ["js"] } -tempfile = "3.1.0" +tempfile = "3.7.1" [[bench]] name = "index" @@ -76,16 +78,20 @@ harness = false ## Wasm section. Crates only used for WASM, as well as specific configurations -[target.'cfg(all(target_arch = "wasm32", target_vendor="unknown"))'.dependencies.wasm-bindgen] -version = "0.2.62" +[target.'cfg(all(target_arch = "wasm32", target_os="unknown"))'.dependencies.wasm-bindgen] +version = "0.2.87" features = ["serde-serialize"] -[target.'cfg(all(target_arch = "wasm32", target_vendor="unknown"))'.dependencies.web-sys] -version = "0.3.57" +[target.'cfg(all(target_arch = "wasm32", target_os="unknown"))'.dependencies.web-sys] +version = "0.3.64" features = ["console", "File"] -[target.'cfg(all(target_arch = "wasm32", target_vendor="unknown"))'.dev-dependencies] -wasm-bindgen-test = "0.3.30" +[target.'cfg(all(target_arch = "wasm32"))'.dependencies.chrono] +version = "0.4.26" +features = ["wasmbind"] + +[target.'cfg(all(target_arch = "wasm32", target_os="unknown"))'.dev-dependencies] +wasm-bindgen-test = "0.3.37" ### These crates don't compile on wasm -[target.'cfg(not(all(target_arch = "wasm32", target_vendor="unknown")))'.dependencies] +[target.'cfg(not(all(target_arch = "wasm32", target_os="unknown")))'.dependencies] diff --git a/src/core/README.md b/src/core/README.md index 1930a063da..64c4ff4346 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -3,14 +3,14 @@ # sourmash 🦀 -[![](http://meritbadge.herokuapp.com/sourmash)](https://crates.io/crates/sourmash) +[![](https://img.shields.io/crates/v/sourmash.svg)](https://crates.io/crates/sourmash) [![Rust API Documentation on docs.rs](https://docs.rs/sourmash/badge.svg)](https://docs.rs/sourmash) [![build-status]][github-actions] -[![codecov](https://codecov.io/gh/dib-lab/sourmash/branch/latest/graph/badge.svg)](https://codecov.io/gh/dib-lab/sourmash) -License: 3-Clause BSD +[![codecov](https://codecov.io/gh/sourmash-bio/sourmash/branch/latest/graph/badge.svg)](https://codecov.io/gh/sourmash-bio/sourmash) +License: 3-Clause BSD -[build-status]: https://github.com/dib-lab/sourmash/workflows/Rust%20checks/badge.svg -[github-actions]: https://github.com/dib-lab/sourmash/actions?query=workflow%3A%22Rust+checks%22 +[build-status]: https://github.com/sourmash-bio/sourmash/workflows/Rust%20checks/badge.svg +[github-actions]: https://github.com/sourmash-bio/sourmash/actions?query=workflow%3A%22Rust+checks%22 ---- @@ -29,13 +29,13 @@ sourmash is a product of the ## Support Please ask questions and files issues -[on Github](https://github.com/dib-lab/sourmash/issues). +[on Github](https://github.com/sourmash-bio/sourmash/issues). ## Development Development happens on github at -[dib-lab/sourmash](https://github.com/dib-lab/sourmash). +[sourmash-bio/sourmash](https://github.com/sourmash-bio/sourmash). ## Minimum supported Rust version -Currently the minimum supported Rust version is 1.48.0. +Currently the minimum supported Rust version is 1.64.0. diff --git a/src/core/build.rs b/src/core/build.rs new file mode 100644 index 0000000000..a22396c25a --- /dev/null +++ b/src/core/build.rs @@ -0,0 +1,75 @@ +use std::env; + +fn main() { + let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + copy_c_bindings(&crate_dir); +} + +#[cfg(not(feature = "maturin"))] +fn copy_c_bindings(_crate_dir: &str) {} + +#[cfg(feature = "maturin")] +fn copy_c_bindings(crate_dir: &str) { + use std::path::{Path, PathBuf}; + + fn find_root_dir(crate_dir: &str) -> &Path { + let root_dir = Path::new(crate_dir); + + if root_dir.join("pyproject.toml").is_file() { + return root_dir; + } + + let root_dir = Path::new(crate_dir).parent().unwrap().parent().unwrap(); + if root_dir.join("pyproject.toml").is_file() { + return root_dir; + } + + panic!("Couldn't find pyproject.toml to determine root dir"); + } + + fn find_target_dir(out_dir: &str) -> PathBuf { + use std::ffi::OsStr; + + let mut components = Path::new(out_dir).iter(); + + while let Some(dir) = components.next_back() { + if dir == OsStr::new("target") { + break; + } + } + let mut dir: PathBuf = components.collect(); + + if dir.as_os_str().is_empty() { + panic!("Couldn't find target dir based on OUT_DIR"); + } else { + dir.push("target"); + dir + } + } + + let root_dir = find_root_dir(crate_dir); + let header_path = root_dir.join("include").join("sourmash.h"); + let header = std::fs::read_to_string(header_path).expect("error reading header"); + + // strip directives, not supported by the cffi C parser + let new_header: String = header + .lines() + .filter_map(|s| { + if s.starts_with("#") { + None + } else { + Some({ + let mut s = s.to_owned(); + s.push_str("\n"); + s + }) + } + }) + .collect(); + + let out_dir = env::var("OUT_DIR").unwrap(); + let target_dir = find_target_dir(&out_dir); + std::fs::create_dir_all(&target_dir).expect("error creating target dir"); + let out_path = target_dir.join("header.h"); + std::fs::write(out_path, &new_header).expect("error writing header"); +} diff --git a/src/core/src/encodings.rs b/src/core/src/encodings.rs index 9749e886e6..6010cf2f6d 100644 --- a/src/core/src/encodings.rs +++ b/src/core/src/encodings.rs @@ -22,7 +22,7 @@ type IdxTracker = (vec_collections::VecSet<[Idx; 4]>, u64); type ColorToIdx = HashMap>; #[allow(non_camel_case_types)] -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u32)] pub enum HashFunctions { murmur64_DNA = 1, @@ -352,9 +352,9 @@ pub fn to_aa(seq: &[u8], dayhoff: bool, hp: bool) -> Result, Error> { let residue = translate_codon(chunk)?; if dayhoff { - converted.push(aa_to_dayhoff(residue) as u8); + converted.push(aa_to_dayhoff(residue)); } else if hp { - converted.push(aa_to_hp(residue) as u8); + converted.push(aa_to_hp(residue)); } else { converted.push(residue); } diff --git a/src/core/src/errors.rs b/src/core/src/errors.rs index e09b3a1701..cd4ddcfaf1 100644 --- a/src/core/src/errors.rs +++ b/src/core/src/errors.rs @@ -24,6 +24,9 @@ pub enum SourmashError { #[error("different signatures cannot be compared")] MismatchSignatureType, + #[error("sketch needs abundance for this operation")] + NeedsAbundanceTracking, + #[error("Invalid hash function: {function:?}")] InvalidHashFunction { function: String }, @@ -60,7 +63,7 @@ pub enum SourmashError { #[error(transparent)] IOError(#[from] std::io::Error), - #[cfg(not(all(target_arch = "wasm32", target_vendor = "unknown")))] + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] #[error(transparent)] Panic(#[from] crate::ffi::utils::Panic), } @@ -88,6 +91,7 @@ pub enum SourmashErrorCode { MismatchSignatureType = 1_05, NonEmptyMinHash = 1_06, MismatchNum = 1_07, + NeedsAbundanceTracking = 1_08, // Input sequence errors InvalidDNA = 11_01, InvalidProt = 11_02, @@ -106,13 +110,16 @@ pub enum SourmashErrorCode { NifflerError = 100_005, } -#[cfg(not(all(target_arch = "wasm32", target_vendor = "unknown")))] +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] impl SourmashErrorCode { pub fn from_error(error: &SourmashError) -> SourmashErrorCode { match error { SourmashError::Internal { .. } => SourmashErrorCode::Internal, SourmashError::Panic { .. } => SourmashErrorCode::Panic, SourmashError::MismatchNum { .. } => SourmashErrorCode::MismatchNum, + SourmashError::NeedsAbundanceTracking { .. } => { + SourmashErrorCode::NeedsAbundanceTracking + } SourmashError::MismatchKSizes => SourmashErrorCode::MismatchKSizes, SourmashError::MismatchDNAProt => SourmashErrorCode::MismatchDNAProt, SourmashError::MismatchScaled => SourmashErrorCode::MismatchScaled, diff --git a/src/core/src/ffi/cmd/compute.rs b/src/core/src/ffi/cmd/compute.rs index 5a808c6b02..a3aa294234 100644 --- a/src/core/src/ffi/cmd/compute.rs +++ b/src/core/src/ffi/cmd/compute.rs @@ -52,7 +52,7 @@ pub unsafe extern "C" fn computeparams_ksizes_free(ptr: *mut u32, insize: usize) if ptr.is_null() { return; } - Vec::from_raw_parts(ptr as *mut u32, insize, insize); + Vec::from_raw_parts(ptr, insize, insize); } ffi_fn! { @@ -65,7 +65,7 @@ unsafe fn computeparams_set_ksizes( let ksizes = { assert!(!ksizes_ptr.is_null()); - slice::from_raw_parts(ksizes_ptr as *const u32, insize) + slice::from_raw_parts(ksizes_ptr, insize) }; cp.set_ksizes(ksizes.into()); diff --git a/src/core/src/ffi/minhash.rs b/src/core/src/ffi/minhash.rs index f7d604e5c0..45890b81d9 100644 --- a/src/core/src/ffi/minhash.rs +++ b/src/core/src/ffi/minhash.rs @@ -39,7 +39,7 @@ pub unsafe extern "C" fn kmerminhash_slice_free(ptr: *mut u64, insize: usize) { if ptr.is_null() { return; } - Vec::from_raw_parts(ptr as *mut u64, insize, insize); + Vec::from_raw_parts(ptr, insize, insize); } ffi_fn! { @@ -232,7 +232,7 @@ unsafe fn kmerminhash_add_many( // FIXME: make a SourmashSlice_u64 type? let hashes = { assert!(!hashes_ptr.is_null()); - slice::from_raw_parts(hashes_ptr as *const u64, insize) + slice::from_raw_parts(hashes_ptr, insize) }; for hash in hashes { @@ -277,13 +277,13 @@ unsafe fn kmerminhash_set_abundances( // FIXME: make a SourmashSlice_u64 type? let hashes = { assert!(!hashes_ptr.is_null()); - slice::from_raw_parts(hashes_ptr as *const u64, insize) + slice::from_raw_parts(hashes_ptr, insize) }; // FIXME: make a SourmashSlice_u64 type? let abunds = { assert!(!abunds_ptr.is_null()); - slice::from_raw_parts(abunds_ptr as *const u64, insize) + slice::from_raw_parts(abunds_ptr, insize) }; let mut pairs: Vec<_> = hashes.iter().cloned().zip(abunds.iter().cloned()).collect(); diff --git a/src/core/src/ffi/nodegraph.rs b/src/core/src/ffi/nodegraph.rs index b5d476cfa0..2e0753b94d 100644 --- a/src/core/src/ffi/nodegraph.rs +++ b/src/core/src/ffi/nodegraph.rs @@ -29,7 +29,7 @@ pub unsafe extern "C" fn nodegraph_buffer_free(ptr: *mut u8, insize: usize) { if ptr.is_null() { return; } - Vec::from_raw_parts(ptr as *mut u8, insize, insize); + Vec::from_raw_parts(ptr, insize, insize); } #[no_mangle] diff --git a/src/core/src/ffi/signature.rs b/src/core/src/ffi/signature.rs index 9ffd5549ff..825e091f4d 100644 --- a/src/core/src/ffi/signature.rs +++ b/src/core/src/ffi/signature.rs @@ -4,6 +4,8 @@ use std::io; use std::os::raw::c_char; use std::slice; +use crate::errors::SourmashError; + use crate::encodings::HashFunctions; use crate::signature::Signature; use crate::sketch::Sketch; @@ -165,11 +167,16 @@ ffi_fn! { unsafe fn signature_first_mh(ptr: *const SourmashSignature) -> Result<*mut SourmashKmerMinHash> { let sig = SourmashSignature::as_rust(ptr); - if let Some(Sketch::MinHash(mh)) = sig.signatures.get(0) { - Ok(SourmashKmerMinHash::from_rust(mh.clone())) - } else { - // TODO: need to select the correct one - unimplemented!() + match sig.signatures.get(0) { + Some(Sketch::MinHash(mh)) => { + Ok(SourmashKmerMinHash::from_rust(mh.clone())) + }, + Some(Sketch::LargeMinHash(mh_btree)) => { + Ok(SourmashKmerMinHash::from_rust(mh_btree.into())) + }, + _ => Err(SourmashError::Internal { + message: "found unsupported sketch type".to_string() + }), } } } diff --git a/src/core/src/ffi/storage.rs b/src/core/src/ffi/storage.rs index 98eca095b2..86d3834201 100644 --- a/src/core/src/ffi/storage.rs +++ b/src/core/src/ffi/storage.rs @@ -64,7 +64,7 @@ unsafe fn zipstorage_list_sbts( // FIXME: use the ForeignObject trait, maybe define new method there... let ptr_sigs: Vec<*mut SourmashStr> = sbts .into_iter() - .map(|x| Box::into_raw(Box::new(SourmashStr::from_string(x))) as *mut SourmashStr) + .map(|x| Box::into_raw(Box::new(SourmashStr::from_string(x)))) .collect(); let b = ptr_sigs.into_boxed_slice(); @@ -86,7 +86,7 @@ unsafe fn zipstorage_filenames( // FIXME: use the ForeignObject trait, maybe define new method there... let ptr_sigs: Vec<*mut SourmashStr> = files .into_iter() - .map(|x| Box::into_raw(Box::new(SourmashStr::from_string(x))) as *mut SourmashStr) + .map(|x| Box::into_raw(Box::new(SourmashStr::from_string(x)))) .collect(); let b = ptr_sigs.into_boxed_slice(); diff --git a/src/core/src/index/linear.rs b/src/core/src/index/linear.rs index c6e14b64f6..78b2c6f1f5 100644 --- a/src/core/src/index/linear.rs +++ b/src/core/src/index/linear.rs @@ -147,7 +147,7 @@ where basepath.push(path); basepath.canonicalize()?; - let linear = LinearIndex::::from_reader(&mut reader, &basepath.parent().unwrap())?; + let linear = LinearIndex::::from_reader(&mut reader, basepath.parent().unwrap())?; Ok(linear) } diff --git a/src/core/src/index/mod.rs b/src/core/src/index/mod.rs index 9ed78af93a..4e43074ebe 100644 --- a/src/core/src/index/mod.rs +++ b/src/core/src/index/mod.rs @@ -197,7 +197,7 @@ impl SigStore { // TODO: better matching here, what if it is not a mh? if let Sketch::MinHash(mh) = &ng.signatures[0] { if let Sketch::MinHash(omh) = &ong.signatures[0] { - return mh.count_common(omh, false).unwrap() as u64; + return mh.count_common(omh, false).unwrap(); } } unimplemented!(); diff --git a/src/core/src/index/revindex.rs b/src/core/src/index/revindex.rs index c39fe79482..0a1fc25d18 100644 --- a/src/core/src/index/revindex.rs +++ b/src/core/src/index/revindex.rs @@ -178,7 +178,7 @@ impl RevIndex { info!("Processed {} reference sigs", i); } - let search_sig = Signature::from_path(&filename) + let search_sig = Signature::from_path(filename) .unwrap_or_else(|_| panic!("Error processing {:?}", filename)) .swap_remove(0); @@ -215,7 +215,7 @@ impl RevIndex { Some( sigs_iter .map(|ref_path| { - Signature::from_path(&ref_path) + Signature::from_path(ref_path) .unwrap_or_else(|_| panic!("Error processing {:?}", ref_path)) .swap_remove(0) }) @@ -394,7 +394,7 @@ impl RevIndex { &refsigs[dataset_id as usize] } else { // TODO: remove swap_remove - ref_match = Signature::from_path(&match_path)?.swap_remove(0); + ref_match = Signature::from_path(match_path)?.swap_remove(0); &ref_match }; @@ -413,7 +413,7 @@ impl RevIndex { let gather_result_rank = matches.len(); let (intersect_orig, _) = match_mh.intersection_size(query)?; - let intersect_bp = (match_mh.scaled() as u64 * intersect_orig) as usize; + let intersect_bp = (match_mh.scaled() * intersect_orig) as usize; let f_unique_to_query = intersect_orig as f64 / query.size() as f64; let match_ = match_sig.clone(); @@ -539,7 +539,7 @@ impl RevIndex { &refsigs[dataset_id as usize] } else { // TODO: remove swap_remove - ref_match = Signature::from_path(&match_path)?.swap_remove(0); + ref_match = Signature::from_path(match_path)?.swap_remove(0); &ref_match }; diff --git a/src/core/src/index/sbt/mod.rs b/src/core/src/index/sbt/mod.rs index 4f4a7b82f9..5245defe1f 100644 --- a/src/core/src/index/sbt/mod.rs +++ b/src/core/src/index/sbt/mod.rs @@ -47,7 +47,7 @@ pub struct SBT { } const fn parent(pos: u64, d: u64) -> u64 { - ((pos - 1) / d) as u64 + (pos - 1) / d } const fn child(parent: u64, pos: u64, d: u64) -> u64 { @@ -287,7 +287,7 @@ where // TODO: canonicalize doesn't work on wasm32-wasi //basepath.canonicalize()?; - let sbt = SBT::, T>::from_reader(&mut reader, &basepath.parent().unwrap())?; + let sbt = SBT::, T>::from_reader(&mut reader, basepath.parent().unwrap())?; Ok(sbt) } @@ -391,9 +391,7 @@ where let mut visited = HashSet::new(); let mut queue = vec![0u64]; - while !queue.is_empty() { - let pos = queue.pop().unwrap(); - + while let Some(pos) = queue.pop() { if !visited.contains(&pos) { visited.insert(pos); @@ -708,9 +706,7 @@ where // - datasets // - first level of internal nodes info!("Start processing leaves"); - while !datasets.is_empty() { - let next_leaf = datasets.pop().unwrap(); - + while let Some(next_leaf) = datasets.pop() { let (simleaf_tree, in_common) = if datasets.is_empty() { (BinaryTree::Empty, next_leaf.mins().into_iter().collect()) } else { @@ -773,8 +769,7 @@ where let mut visited = HashSet::new(); let mut queue = vec![(0u64, root)]; - while !queue.is_empty() { - let (pos, cnode) = queue.pop().unwrap(); + while let Some((pos, cnode)) = queue.pop() { if !visited.contains(&pos) { visited.insert(pos); @@ -804,9 +799,7 @@ impl BinaryTree { fn process_internal_level(mut current_round: Vec) -> Vec { let mut next_round = Vec::with_capacity(current_round.len() + 1); - while !current_round.is_empty() { - let next_node = current_round.pop().unwrap(); - + while let Some(next_node) = current_round.pop() { let similar_node = if current_round.is_empty() { BinaryTree::Empty } else { diff --git a/src/core/src/lib.rs b/src/core/src/lib.rs index 14a25ab632..66de82e6a0 100644 --- a/src/core/src/lib.rs +++ b/src/core/src/lib.rs @@ -39,7 +39,8 @@ use cfg_if::cfg_if; use murmurhash3::murmurhash3_x64_128; cfg_if! { - if #[cfg(all(target_arch = "wasm32", target_vendor = "unknown"))] { + if #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] { + // Explicitly keeping emscripten and wasi out of this pub mod wasm; } else { pub mod ffi; diff --git a/src/core/src/signature.rs b/src/core/src/signature.rs index 0c6a2676bf..db2a85ea05 100644 --- a/src/core/src/signature.rs +++ b/src/core/src/signature.rs @@ -210,7 +210,7 @@ impl SeqToHashes { sequence: seq.to_ascii_uppercase(), k_size: ksize, kmer_index: 0, - max_index: _max_index as usize, + max_index: _max_index, force, is_protein, hash_function, @@ -248,7 +248,7 @@ impl Iterator for SeqToHashes { if !self.is_protein { // Setting the parameters only in the first iteration if !self.dna_configured { - self.dna_ksize = self.k_size as usize; + self.dna_ksize = self.k_size; self.dna_len = self.sequence.len(); if self.dna_len < self.dna_ksize || (!self.hash_function.dna() && self.dna_len < self.k_size * 3) @@ -320,7 +320,7 @@ impl Iterator for SeqToHashes { ) .unwrap(); - aa.windows(self.k_size as usize).for_each(|n| { + aa.windows(self.k_size).for_each(|n| { let hash = crate::_hash_murmur(n, self.seed); self.hashes_buffer.push(hash); }); @@ -339,7 +339,7 @@ impl Iterator for SeqToHashes { ) .unwrap(); - aa_rc.windows(self.k_size as usize).for_each(|n| { + aa_rc.windows(self.k_size).for_each(|n| { let hash = crate::_hash_murmur(n, self.seed); self.hashes_buffer.push(hash); }); @@ -570,7 +570,7 @@ impl Signature { match sig { Sketch::MinHash(mh) => { if let Some(k) = ksize { - if k != mh.ksize() as usize { + if k != mh.ksize() { return false; } }; @@ -586,7 +586,7 @@ impl Signature { } Sketch::LargeMinHash(mh) => { if let Some(k) = ksize { - if k != mh.ksize() as usize { + if k != mh.ksize() { return false; } }; diff --git a/src/core/src/sketch/hyperloglog/estimators.rs b/src/core/src/sketch/hyperloglog/estimators.rs index 60eadc5201..8f138500ea 100644 --- a/src/core/src/sketch/hyperloglog/estimators.rs +++ b/src/core/src/sketch/hyperloglog/estimators.rs @@ -28,7 +28,7 @@ pub fn mle(counts: &[u16], p: usize, q: usize, relerr: f64) -> f64 { .rev() .find(|(_, v)| **v != 0) .unwrap(); - let k_max_prime = cmp::min(q, k_max as usize); + let k_max_prime = cmp::min(q, k_max); let mut z = 0.; for i in num_iter::range_step_inclusive(k_max_prime as i32, k_min_prime as i32, -1) { diff --git a/src/core/src/sketch/hyperloglog/mod.rs b/src/core/src/sketch/hyperloglog/mod.rs index 013503d31b..409d2a2c44 100644 --- a/src/core/src/sketch/hyperloglog/mod.rs +++ b/src/core/src/sketch/hyperloglog/mod.rs @@ -25,7 +25,7 @@ use crate::HashIntoType; pub mod estimators; use estimators::CounterType; -#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct HyperLogLog { registers: Vec, p: usize, @@ -170,7 +170,7 @@ impl SigsTrait for HyperLogLog { } fn ksize(&self) -> usize { - self.ksize as usize + self.ksize } fn seed(&self) -> u64 { diff --git a/src/core/src/sketch/minhash.rs b/src/core/src/sketch/minhash.rs index 7557159839..5c5f1114f8 100644 --- a/src/core/src/sketch/minhash.rs +++ b/src/core/src/sketch/minhash.rs @@ -644,8 +644,7 @@ impl KmerMinHash { self.check_compatible(other)?; if self.abunds.is_none() || other.abunds.is_none() { - // TODO: throw error, we need abundance for this - unimplemented!() // @CTB fixme + return Err(Error::NeedsAbundanceTracking); } // TODO: check which one is smaller, swap around if needed @@ -778,21 +777,7 @@ impl KmerMinHash { // create a downsampled copy of self pub fn downsample_scaled(&self, scaled: u64) -> Result { let max_hash = max_hash_for_scaled(scaled); - - let mut new_mh = KmerMinHash::new( - max_hash, // old max_hash => max_hash arg - self.ksize, - self.hash_function, - self.seed, - self.abunds.is_some(), - self.num, - ); - if self.abunds.is_some() { - new_mh.add_many_with_abund(&self.to_vec_abunds())?; - } else { - new_mh.add_many(&self.mins)?; - } - Ok(new_mh) + self.downsample_max_hash(max_hash) } } @@ -1072,7 +1057,7 @@ impl<'de> Deserialize<'de> for KmerMinHashBTree { values.sort(); let mins: BTreeSet<_> = values.iter().map(|(v, _)| **v).collect(); let abunds = values.into_iter().map(|(v, x)| (*v, *x)).collect(); - current_max = *mins.iter().rev().next().unwrap_or(&0); + current_max = *mins.iter().next_back().unwrap_or(&0); (mins, Some(abunds)) } else { current_max = 0; @@ -1259,13 +1244,13 @@ impl KmerMinHashBTree { // is it too big now? if self.num != 0 && self.mins.len() > (self.num as usize) { - let last = *self.mins.iter().rev().next().unwrap(); + let last = *self.mins.iter().next_back().unwrap(); self.mins.remove(&last); self.reset_md5sum(); if let Some(ref mut abunds) = self.abunds { abunds.remove(&last); } - self.current_max = *self.mins.iter().rev().next().unwrap(); + self.current_max = *self.mins.iter().next_back().unwrap(); } } } @@ -1283,7 +1268,7 @@ impl KmerMinHashBTree { } } if hash == self.current_max { - self.current_max = *self.mins.iter().rev().next().unwrap_or(&0); + self.current_max = *self.mins.iter().next_back().unwrap_or(&0); } } @@ -1444,8 +1429,7 @@ impl KmerMinHashBTree { self.check_compatible(other)?; if self.abunds.is_none() || other.abunds.is_none() { - // TODO: throw error, we need abundance for this - unimplemented!() // @CTB fixme + return Err(Error::NeedsAbundanceTracking); } let abunds = self.abunds.as_ref().unwrap(); @@ -1539,6 +1523,12 @@ impl KmerMinHashBTree { Ok(new_mh) } + // create a downsampled copy of self + pub fn downsample_scaled(&self, scaled: u64) -> Result { + let max_hash = max_hash_for_scaled(scaled); + self.downsample_max_hash(max_hash) + } + pub fn to_vec_abunds(&self) -> Vec<(u64, u64)> { if let Some(abunds) = &self.abunds { abunds.iter().map(|(a, b)| (*a, *b)).collect() @@ -1627,6 +1617,30 @@ impl From for KmerMinHash { } } +impl From<&KmerMinHashBTree> for KmerMinHash { + fn from(other: &KmerMinHashBTree) -> KmerMinHash { + let mut new_mh = KmerMinHash::new( + other.scaled(), + other.ksize() as u32, + other.hash_function(), + other.seed(), + other.track_abundance(), + other.num(), + ); + + let mins = other.mins.iter().copied().collect(); + let abunds = other + .abunds + .as_ref() + .map(|abunds| abunds.values().cloned().collect()); + + new_mh.mins = mins; + new_mh.abunds = abunds; + + new_mh + } +} + impl From for KmerMinHashBTree { fn from(other: KmerMinHash) -> KmerMinHashBTree { let mut new_mh = KmerMinHashBTree::new( @@ -1641,7 +1655,7 @@ impl From for KmerMinHashBTree { let mins: BTreeSet = other.mins.into_iter().collect(); let abunds = other .abunds - .map(|abunds| mins.iter().cloned().zip(abunds.into_iter()).collect()); + .map(|abunds| mins.iter().cloned().zip(abunds).collect()); new_mh.mins = mins; new_mh.abunds = abunds; diff --git a/src/core/src/sketch/nodegraph.rs b/src/core/src/sketch/nodegraph.rs index 9c288ce3ad..cbca8915ba 100644 --- a/src/core/src/sketch/nodegraph.rs +++ b/src/core/src/sketch/nodegraph.rs @@ -139,7 +139,7 @@ impl Nodegraph { let occupancy = self.occupied_bins; let fp_one = occupancy as f64 / min_size as f64; - f64::powf(fp_one as f64, n_ht as f64) + f64::powf(fp_one, n_ht as f64) } pub fn tablesize(&self) -> usize { @@ -197,9 +197,7 @@ impl Nodegraph { //wtr.write_u32_from::(&count.as_slice()[..div])?; let slice = &count.as_slice()[..div]; let buf = unsafe { - use std::mem::size_of; - - let len = size_of::() * slice.len(); + let len = std::mem::size_of_val(slice); slice::from_raw_parts(slice.as_ptr() as *const u8, len) }; wtr.write_all(buf)?; diff --git a/src/core/src/storage.rs b/src/core/src/storage.rs index a2269bd6e7..ec82464c5b 100644 --- a/src/core/src/storage.rs +++ b/src/core/src/storage.rs @@ -3,7 +3,8 @@ use std::ffi::OsStr; use std::fs::{DirBuilder, File}; use std::io::{BufReader, BufWriter, Read, Write}; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; +use std::rc::Rc; +use std::sync::RwLock; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -24,11 +25,11 @@ pub trait Storage { } #[derive(Clone)] -pub struct InnerStorage(Arc>); +pub struct InnerStorage(Rc>); impl InnerStorage { pub fn new(inner: impl Storage + 'static) -> InnerStorage { - InnerStorage(Arc::new(Mutex::new(inner))) + InnerStorage(Rc::new(RwLock::new(inner))) } } @@ -85,20 +86,20 @@ impl From<&StorageArgs> for FSStorage { } } -impl Storage for Mutex +impl Storage for RwLock where L: ?Sized + Storage, { fn save(&self, path: &str, content: &[u8]) -> Result { - self.lock().unwrap().save(path, content) + self.read().unwrap().save(path, content) } fn load(&self, path: &str) -> Result, Error> { - self.lock().unwrap().load(path) + self.read().unwrap().load(path) } fn args(&self) -> StorageArgs { - self.lock().unwrap().args() + self.read().unwrap().args() } } diff --git a/src/core/tests/minhash.rs b/src/core/tests/minhash.rs index e04667fbfe..bcb3fdb4fa 100644 --- a/src/core/tests/minhash.rs +++ b/src/core/tests/minhash.rs @@ -8,7 +8,9 @@ use proptest::proptest; use sourmash::encodings::HashFunctions; use sourmash::signature::SeqToHashes; use sourmash::signature::{Signature, SigsTrait}; -use sourmash::sketch::minhash::{max_hash_for_scaled, KmerMinHash, KmerMinHashBTree}; +use sourmash::sketch::minhash::{ + max_hash_for_scaled, scaled_for_max_hash, KmerMinHash, KmerMinHashBTree, +}; use sourmash::sketch::Sketch; // TODO: use f64::EPSILON when we bump MSRV @@ -122,6 +124,32 @@ fn similarity_3() -> Result<(), Box> { Ok(()) } +#[test] +fn angular_similarity_requires_abundance() -> Result<(), Box> { + let mut a = KmerMinHash::new(0, 20, HashFunctions::murmur64_dayhoff, 42, false, 5); + let mut b = KmerMinHash::new(0, 20, HashFunctions::murmur64_dayhoff, 42, false, 5); + + a.add_hash(1); + b.add_hash(1); + + assert!(a.angular_similarity(&b).is_err()); + + Ok(()) +} + +#[test] +fn angular_similarity_btree_requires_abundance() -> Result<(), Box> { + let mut a = KmerMinHashBTree::new(0, 20, HashFunctions::murmur64_dayhoff, 42, false, 5); + let mut b = KmerMinHashBTree::new(0, 20, HashFunctions::murmur64_dayhoff, 42, false, 5); + + a.add_hash(1); + b.add_hash(1); + + assert!(a.angular_similarity(&b).is_err()); + + Ok(()) +} + #[test] fn dayhoff() { let mut a = KmerMinHash::new(0, 6, HashFunctions::murmur64_dayhoff, 42, false, 10); @@ -251,7 +279,8 @@ fn oracle_mins_scaled(hashes in vec(u64::ANY, 1..10000)) { assert_eq!(c.count_common(&a, true).unwrap(), d.count_common(&b, true).unwrap()); let mut e = a.downsample_max_hash(100).unwrap(); - let mut f = b.downsample_max_hash(100).unwrap(); + let scaled = scaled_for_max_hash(100); + let mut f = b.downsample_scaled(scaled).unwrap(); // Can't compare different scaled without explicit downsample assert!(c.similarity(&e, false, false).is_err()); @@ -327,7 +356,8 @@ fn prop_merge(seq1 in "[ACGT]{6,100}", seq2 in "[ACGT]{6,200}") { assert!((a.similarity(&c, true, false).unwrap() - b.similarity(&d, true, false).unwrap()).abs() < EPSILON); let mut e = a.downsample_max_hash(100).unwrap(); - let mut f = b.downsample_max_hash(100).unwrap(); + let scaled = scaled_for_max_hash(100); + let mut f = b.downsample_scaled(scaled).unwrap(); assert!((e.similarity(&c, false, true).unwrap() - f.similarity(&d, false, true).unwrap()).abs() < EPSILON); assert!((e.similarity(&c, true, true).unwrap() - f.similarity(&d, true, true).unwrap()).abs() < EPSILON); diff --git a/src/sourmash/__init__.py b/src/sourmash/__init__.py index 8735c06cea..33170edcd8 100644 --- a/src/sourmash/__init__.py +++ b/src/sourmash/__init__.py @@ -1,5 +1,5 @@ -"""A library for computing hash sketches from DNA sequences, comparing -them to each other, and plotting the results. +"""A library for creating k-mer sketches from biological sequences, comparing +them to each other, and working with the results. Public API: @@ -12,10 +12,11 @@ class MinHash - hash sketch class Please see https://sourmash.readthedocs.io/en/latest/api.html for API docs. -The sourmash code is available at github.com/dib-lab/sourmash/ under the +The sourmash code is available at github.com/sourmash-bio/sourmash/ under the BSD 3-Clause license. """ from deprecation import deprecated +from importlib.metadata import version __all__ = ['MinHash', 'SourmashSignature', 'load_one_signature', @@ -33,19 +34,7 @@ class MinHash - hash sketch class ffi.init_once(lib.sourmash_init, "init") -from pkg_resources import get_distribution, DistributionNotFound - -try: - VERSION = get_distribution(__name__).version -except DistributionNotFound: # pragma: no cover - try: - from .version import version as VERSION # noqa - except ImportError: # pragma: no cover - raise ImportError( - "Failed to find (autogenerated) version.py. " - "This might be because you are installing from GitHub's tarballs, " - "use the PyPI ones." - ) +VERSION = version(__name__) from .minhash import MinHash, get_minhash_default_seed, get_minhash_max_hash diff --git a/src/sourmash/__main__.py b/src/sourmash/__main__.py index ef6b8665c4..74fdf270c0 100644 --- a/src/sourmash/__main__.py +++ b/src/sourmash/__main__.py @@ -1,8 +1,13 @@ -import sourmash +""" +The main entry point for sourmash, defined in pyproject.toml. + +Can also be executed as 'python -m sourmash'. +""" def main(arglist=None): - args = sourmash.cli.get_parser().parse_args(arglist) + import sourmash + args = sourmash.cli.parse_args(arglist) if hasattr(args, 'subcmd'): mod = getattr(sourmash.cli, args.cmd) submod = getattr(mod, args.subcmd) @@ -10,7 +15,9 @@ def main(arglist=None): else: mod = getattr(sourmash.cli, args.cmd) mainmethod = getattr(mod, 'main') - return mainmethod(args) + + retval = mainmethod(args) + raise SystemExit(retval) if __name__ == '__main__': diff --git a/src/sourmash/cli/__init__.py b/src/sourmash/cli/__init__.py index deaf8517ff..575bbdb0f5 100644 --- a/src/sourmash/cli/__init__.py +++ b/src/sourmash/cli/__init__.py @@ -38,6 +38,7 @@ from . import sketch from . import storage from . import tax +from . import scripts class SourmashParser(ArgumentParser): @@ -98,20 +99,25 @@ def get_parser(): 'sketch': 'Create signatures', 'sig': 'Manipulate signature files', 'storage': 'Operations on storage', + 'scripts': "Plug-ins", } alias = { - "sig": "signature" + "sig": "signature", + "ext": "scripts", } expert = set(['categorize', 'import_csv', 'migrate', 'multigather', 'sbt_combine', 'watch']) clidir = os.path.dirname(__file__) basic_ops = utils.command_list(clidir) - user_ops = [op for op in basic_ops if op not in expert] + + # provide a list of the basic operations - not expert, not submodules. + user_ops = [op for op in basic_ops if op not in expert and op not in module_descs] usage = ' Basic operations\n' for op in user_ops: docstring = getattr(sys.modules[__name__], op).__doc__ helpstring = 'sourmash {op:s} --help'.format(op=op) usage += ' {hs:25s} {ds:s}\n'.format(hs=helpstring, ds=docstring) + # next, all the subcommand ones - dive into subdirectories. cmd_group_dirs = next(os.walk(clidir))[1] cmd_group_dirs = filter(utils.opfilter, cmd_group_dirs) cmd_group_dirs = sorted(cmd_group_dirs) @@ -123,7 +129,7 @@ def get_parser(): if dirpath in alias: usage += ' sourmash {gd:s} --help\n'.format(gd=alias[dirpath]) - desc = 'Compute, compare, manipulate, and analyze MinHash sketches of DNA sequences.\n\nUsage instructions:\n' + usage + desc = 'Create, compare, and manipulate k-mer sketches of biological sequences.\n\nUsage instructions:\n' + usage parser = SourmashParser(prog='sourmash', description=desc, formatter_class=RawDescriptionHelpFormatter, usage=SUPPRESS) parser._optionals.title = 'Options' parser.add_argument('-v', '--version', action='version', version='sourmash '+ sourmash.VERSION) @@ -135,3 +141,20 @@ def get_parser(): getattr(sys.modules[__name__], op).subparser(sub) parser._action_groups.reverse() return parser + + +def parse_args(arglist=None): + """ + Return an argparse 'args' object from parsing arglist. + + By default pulls arguments from sys.argv. + + Example usage: + + ``` + args = parse_args(['sig', 'filter', '-m', '10']) + + sourmash.sig.filter.__main__.filter(args) + ``` + """ + return get_parser().parse_args(arglist) diff --git a/src/sourmash/cli/categorize.py b/src/sourmash/cli/categorize.py index eb8c995f8d..e3c41ec773 100644 --- a/src/sourmash/cli/categorize.py +++ b/src/sourmash/cli/categorize.py @@ -14,7 +14,7 @@ def subparser(subparsers): '-q', '--quiet', action='store_true', help='suppress non-error output' ) - add_ksize_arg(subparser, 31) + add_ksize_arg(subparser) subparser.add_argument( '--threshold', default=0.08, type=float, help='minimum threshold for reporting matches; default=0.08' diff --git a/src/sourmash/cli/compare.py b/src/sourmash/cli/compare.py index 81fdf1c5d1..54864d6c93 100644 --- a/src/sourmash/cli/compare.py +++ b/src/sourmash/cli/compare.py @@ -1,4 +1,4 @@ -"""compare sequence signatures made by compute""" +"""create a similarity matrix comparing many samples""" usage=""" @@ -9,9 +9,10 @@ The default output is a text display of a similarity matrix where each entry `[i, j]` contains the estimated Jaccard index between input signature `i` and input signature `j`. The output matrix can be saved -to a file with `--output` and used with the `sourmash plot` subcommand -(or loaded with `numpy.load(...)`. Using `--csv` will output a CSV -file that can be loaded into other languages than Python, such as R. +to a file with `--output ` and used with the `sourmash +plot` subcommand (or loaded with `numpy.load(...)`. Using `--csv +` will output a CSV file that can be loaded into other +languages than Python, such as R. Command line usage: ``` @@ -27,7 +28,8 @@ """ from sourmash.cli.utils import (add_ksize_arg, add_moltype_args, - add_picklist_args, add_pattern_args) + add_picklist_args, add_pattern_args, + add_scaled_arg) def subparser(subparsers): @@ -39,8 +41,6 @@ def subparser(subparsers): subparser.add_argument( '-q', '--quiet', action='store_true', help='suppress non-error output' ) - add_ksize_arg(subparser) - add_moltype_args(subparser) subparser.add_argument( '-o', '--output', metavar='F', help='file to which output will be written; default is terminal ' @@ -82,8 +82,21 @@ def subparser(subparsers): subparser.add_argument( '-p', '--processes', metavar='N', type=int, default=None, help='Number of processes to use to calculate similarity') + subparser.add_argument( + '--distance-matrix', action='store_true', + help='output a distance matrix, instead of a similarity matrix' + ) + subparser.add_argument( + '--similarity-matrix', action='store_false', + dest='distance_matrix', + help='output a similarity matrix; this is the default', + ) + + add_ksize_arg(subparser) + add_moltype_args(subparser) add_picklist_args(subparser) add_pattern_args(subparser) + add_scaled_arg(subparser) def main(args): diff --git a/src/sourmash/cli/gather.py b/src/sourmash/cli/gather.py index 1260b1d035..732fef52bf 100644 --- a/src/sourmash/cli/gather.py +++ b/src/sourmash/cli/gather.py @@ -135,7 +135,22 @@ def subparser(subparsers): '--estimate-ani-ci', action='store_true', help='also output confidence intervals for ANI estimates' ) - add_ksize_arg(subparser, 31) + subparser.add_argument( + '--fail-on-empty-database', action='store_true', + help='stop at databases that contain no compatible signatures' + ) + subparser.add_argument( + '--no-fail-on-empty-database', action='store_false', + dest='fail_on_empty_database', + help='continue past databases that contain no compatible signatures' + ) + subparser.set_defaults(fail_on_empty_database=True) + subparser.add_argument( + '--create-empty-results', action='store_true', + help='create an empty results file even if no matches.' + ) + + add_ksize_arg(subparser) add_moltype_args(subparser) add_picklist_args(subparser) add_pattern_args(subparser) diff --git a/src/sourmash/cli/index.py b/src/sourmash/cli/index.py index 4737a8a8b9..dcd8572ca0 100644 --- a/src/sourmash/cli/index.py +++ b/src/sourmash/cli/index.py @@ -66,7 +66,7 @@ def subparser(subparsers): help='What percentage of internal nodes will not be saved; ranges ' 'from 0.0 (save all nodes) to 1.0 (no nodes saved)' ) - add_ksize_arg(subparser, 31) + add_ksize_arg(subparser) add_moltype_args(subparser) add_picklist_args(subparser) add_scaled_arg(subparser, 0) diff --git a/src/sourmash/cli/info.py b/src/sourmash/cli/info.py index 26211ecb84..b607112b7c 100644 --- a/src/sourmash/cli/info.py +++ b/src/sourmash/cli/info.py @@ -4,6 +4,7 @@ import screed import sourmash from sourmash.logging import notify +from sourmash.plugins import list_all_plugins def subparser(subparsers): subparser = subparsers.add_parser('info') @@ -26,6 +27,8 @@ def info(verbose=False): notify(f'screed version {screed.__version__}') notify(f'- loaded from path: {os.path.dirname(screed.__file__)}') + list_all_plugins() + def main(args): info(verbose=args.verbose) diff --git a/src/sourmash/cli/lca/index.py b/src/sourmash/cli/lca/index.py index 14c6cca1b2..3e1e456273 100644 --- a/src/sourmash/cli/lca/index.py +++ b/src/sourmash/cli/lca/index.py @@ -66,7 +66,7 @@ def subparser(subparsers): choices=['json', 'sql'], ) - add_ksize_arg(subparser, 31) + add_ksize_arg(subparser, default=31) add_moltype_args(subparser) add_picklist_args(subparser) diff --git a/src/sourmash/cli/multigather.py b/src/sourmash/cli/multigather.py index b2393f751f..cf20a32cd2 100644 --- a/src/sourmash/cli/multigather.py +++ b/src/sourmash/cli/multigather.py @@ -72,7 +72,23 @@ def subparser(subparsers): '--estimate-ani-ci', action='store_true', help='also output confidence intervals for ANI estimates' ) - add_ksize_arg(subparser, 31) + subparser.add_argument( + '--fail-on-empty-database', action='store_true', + help='stop at databases that contain no compatible signatures' + ) + subparser.add_argument( + '--no-fail-on-empty-database', action='store_false', + dest='fail_on_empty_database', + help='continue past databases that contain no compatible signatures' + ) + subparser.set_defaults(fail_on_empty_database=True) + + subparser.add_argument( + '--output-dir', '--outdir', + help='output CSV results to this directory', + ) + + add_ksize_arg(subparser) add_moltype_args(subparser) add_scaled_arg(subparser, 0) diff --git a/src/sourmash/cli/prefetch.py b/src/sourmash/cli/prefetch.py index 8fcb36f000..3727960292 100644 --- a/src/sourmash/cli/prefetch.py +++ b/src/sourmash/cli/prefetch.py @@ -62,7 +62,7 @@ def subparser(subparsers): '--estimate-ani-ci', action='store_true', help='also output confidence intervals for ANI estimates' ) - add_ksize_arg(subparser, 31) + add_ksize_arg(subparser) add_moltype_args(subparser) add_picklist_args(subparser) add_pattern_args(subparser) diff --git a/src/sourmash/cli/sbt_combine.py b/src/sourmash/cli/sbt_combine.py index 2e708b928d..1b5ce0febf 100644 --- a/src/sourmash/cli/sbt_combine.py +++ b/src/sourmash/cli/sbt_combine.py @@ -11,16 +11,6 @@ def subparser(subparsers): '-x', '--bf-size', metavar='S', type=float, default=1e5 ) - subparser = subparsers.add_parser('sbt_combine') - subparser.add_argument('sbt_name', help='name to save SBT into') - subparser.add_argument( - 'sbts', nargs='+', - help='SBTs to combine to form a new SBT' - ) - subparser.add_argument( - '-x', '--bf-size', metavar='S', type=float, default=1e5 - ) - def main(args): import sourmash diff --git a/src/sourmash/cli/scripts/__init__.py b/src/sourmash/cli/scripts/__init__.py new file mode 100644 index 0000000000..7062ff6c71 --- /dev/null +++ b/src/sourmash/cli/scripts/__init__.py @@ -0,0 +1,48 @@ +"""Provide a mechanism to add CLI plugins to sourmash. + +See https://sourmash.readthedocs.io/en/latest/dev_plugins.html for docs, +src/sourmash/plugins.py for core sourmash implementation code, and +https://github.com/sourmash-bio/sourmash_plugin_template for a template repo +for making new plugins. +""" + +# CTB TODO: +# * provide suggestions for documentation & metadata for authors: +# * provide guidance on how to test your CLI plugin at the CLI +# (minimal testing regime: sourmash scripts, look for description etc.) + +import argparse +import sourmash + +# Here, we decorate this module with the various extension objects +# e.g. 'sourmash scripts foo' will look up attribute 'scripts.foo' +# and we will return the extension class object, which will then +# be run by sourmash.__main__. This dictionary is loaded below +# by sourmash.plugins.add_cli_scripts. +_extension_dict = {} + +def __getattr__(name): + if name in _extension_dict: + return _extension_dict[name] + raise AttributeError(name) + +def subparser(subparsers): + subparser = subparsers.add_parser('scripts', + usage=argparse.SUPPRESS, + formatter_class=argparse.RawDescriptionHelpFormatter, + aliases=['ext']) + + # get individual help strings: + descrs = list(sourmash.plugins.get_cli_scripts_descriptions()) + if descrs: + description = "\n".join(descrs) + else: + description = "(No script plugins detected!)" + + s = subparser.add_subparsers(title="available plugin/extension commands", + dest='subcmd', + metavar='subcmd', + help=argparse.SUPPRESS, + description=description) + + _extension_dict.update(sourmash.plugins.add_cli_scripts(s)) diff --git a/src/sourmash/cli/search.py b/src/sourmash/cli/search.py index 53b5bb9b8b..fc37367d2e 100644 --- a/src/sourmash/cli/search.py +++ b/src/sourmash/cli/search.py @@ -61,7 +61,7 @@ def subparser(subparsers): help='output debug information' ) subparser.add_argument( - '--threshold', metavar='T', default=0.08, type=float, + '-t', '--threshold', metavar='T', default=0.08, type=float, help='minimum threshold for reporting matches; default=0.08' ) subparser.add_argument( @@ -74,7 +74,7 @@ def subparser(subparsers): ) subparser.add_argument( '-n', '--num-results', default=3, type=int, metavar='N', - help='number of results to report' + help='number of results to display to user; 0 to report all' ) subparser.add_argument( '--containment', action='store_true', @@ -101,7 +101,18 @@ def subparser(subparsers): '--md5', default=None, help='select the signature with this md5 as query' ) - add_ksize_arg(subparser, 31) + subparser.add_argument( + '--fail-on-empty-database', action='store_true', + help='stop at databases that contain no compatible signatures' + ) + subparser.add_argument( + '--no-fail-on-empty-database', action='store_false', + dest='fail_on_empty_database', + help='continue past databases that contain no compatible signatures' + ) + subparser.set_defaults(fail_on_empty_database=True) + + add_ksize_arg(subparser) add_moltype_args(subparser) add_picklist_args(subparser) add_pattern_args(subparser) diff --git a/src/sourmash/cli/sig/cat.py b/src/sourmash/cli/sig/cat.py index 2959d43c4c..ed85932f5f 100644 --- a/src/sourmash/cli/sig/cat.py +++ b/src/sourmash/cli/sig/cat.py @@ -1,11 +1,27 @@ """concatenate signature files""" +usage=""" + +### `sourmash signature cat` - concatenate multiple signatures together + +Concatenate signature files. + +For example, + +sourmash signature cat file1.sig file2.sig -o all.sig + +will combine all signatures in `file1.sig` and `file2.sig` and put them +in the file `all.sig`. + +""" + from sourmash.cli.utils import (add_moltype_args, add_ksize_arg, add_picklist_args, add_pattern_args) def subparser(subparsers): - subparser = subparsers.add_parser('cat') + # working on this + subparser = subparsers.add_parser('cat', description=__doc__, usage=usage) subparser.add_argument('signatures', nargs='*') subparser.add_argument( '--from-file', @@ -15,6 +31,10 @@ def subparser(subparsers): '-q', '--quiet', action='store_true', help='suppress non-error output' ) + subparser.add_argument( + '-d', '--debug', action='store_true', + help='provide debugging output' + ) subparser.add_argument( '-o', '--output', metavar='FILE', default='-', help='output signature to this file (default stdout)' @@ -27,7 +47,7 @@ def subparser(subparsers): '-f', '--force', action='store_true', help='try to load all files as signatures' ) - add_ksize_arg(subparser, 31) + add_ksize_arg(subparser) add_moltype_args(subparser) add_pattern_args(subparser) add_picklist_args(subparser) diff --git a/src/sourmash/cli/sig/check.py b/src/sourmash/cli/sig/check.py index d8308173aa..b9dd353501 100644 --- a/src/sourmash/cli/sig/check.py +++ b/src/sourmash/cli/sig/check.py @@ -62,7 +62,7 @@ def subparser(subparsers): choices=['csv', 'sql'], ) - add_ksize_arg(subparser, 31) + add_ksize_arg(subparser) add_moltype_args(subparser) add_pattern_args(subparser) add_picklist_args(subparser) diff --git a/src/sourmash/cli/sig/collect.py b/src/sourmash/cli/sig/collect.py index d44aaa9a39..397b0bf34e 100644 --- a/src/sourmash/cli/sig/collect.py +++ b/src/sourmash/cli/sig/collect.py @@ -53,7 +53,7 @@ def subparser(subparsers): help="convert all locations to absolute paths", action='store_true') - add_ksize_arg(subparser, 31) + add_ksize_arg(subparser) add_moltype_args(subparser) diff --git a/src/sourmash/cli/sig/describe.py b/src/sourmash/cli/sig/describe.py index 79833da9a8..c59ea1fede 100644 --- a/src/sourmash/cli/sig/describe.py +++ b/src/sourmash/cli/sig/describe.py @@ -1,11 +1,33 @@ """show details of signature""" +usage=""" + +### `sourmash signature describe` - display detailed information about signatures + +Display signature details. + +For example, + +sourmash sig describe tests/test-data/47.fa.sig + +will display: + +signature filename: tests/test-data/47.fa.sig +signature: NC_009665.1 Shewanella baltica OS185, complete genome +source file: 47.fa +md5: 09a08691ce52952152f0e866a59f6261 +k=31 molecule=DNA num=0 scaled=1000 seed=42 track_abundance=0 +size: 5177 +signature license: CC0 + +""" + from sourmash.cli.utils import (add_moltype_args, add_ksize_arg, add_picklist_args, add_pattern_args) def subparser(subparsers): - subparser = subparsers.add_parser('describe') + subparser = subparsers.add_parser('describe', description=__doc__, usage=usage) subparser.add_argument('signatures', nargs='*') subparser.add_argument( '-q', '--quiet', action='store_true', @@ -27,7 +49,7 @@ def subparser(subparsers): '--from-file', help='a text file containing a list of files to load signatures from' ) - add_ksize_arg(subparser, 31) + add_ksize_arg(subparser) add_moltype_args(subparser) add_picklist_args(subparser) add_pattern_args(subparser) diff --git a/src/sourmash/cli/sig/downsample.py b/src/sourmash/cli/sig/downsample.py index 31b5f434d5..a06b7d2eb5 100644 --- a/src/sourmash/cli/sig/downsample.py +++ b/src/sourmash/cli/sig/downsample.py @@ -1,11 +1,37 @@ """downsample one or more signatures""" +usage=""" + +### `sourmash signature downsample` - decrease the size of a signature + +Downsample one or more signatures. + +With `downsample`, you can -- + +* increase the `scaled` value for a signature created with `-p scaled=SCALED`, shrinking it in size; +* decrease the `num` value for a traditional num MinHash, shrinking it in size; +* try to convert a `scaled` signature to a `num` signature; +* try to convert a `num` signature to a `scaled` signature. + +For example, + +sourmash signature downsample file1.sig file2.sig --scaled 100000 -o downsampled.sig + +will output each signature, downsampled to a scaled value of 100000, to +`downsampled.sig`; and + +sourmash signature downsample --num 500 scaled_file.sig -o downsampled.sig + +will try to convert a scaled MinHash to a num MinHash. + +""" + from sourmash.cli.utils import (add_moltype_args, add_ksize_arg, add_picklist_args, add_num_arg) def subparser(subparsers): - subparser = subparsers.add_parser('downsample') + subparser = subparsers.add_parser('downsample', description=__doc__, usage=usage) subparser.add_argument('signatures', nargs="*") subparser.add_argument( '--scaled', type=int, default=0, @@ -28,7 +54,7 @@ def subparser(subparsers): '-f', '--force', action='store_true', help='try to load all files as signatures' ) - add_ksize_arg(subparser, 31) + add_ksize_arg(subparser) add_moltype_args(subparser) add_picklist_args(subparser) add_num_arg(subparser, 0) diff --git a/src/sourmash/cli/sig/export.py b/src/sourmash/cli/sig/export.py index 5dfe2793d1..0299dba5d1 100644 --- a/src/sourmash/cli/sig/export.py +++ b/src/sourmash/cli/sig/export.py @@ -1,10 +1,23 @@ """export a signature, e.g. to mash""" +usage=""" + +### `sourmash signature export` - export signatures to mash. + +Export signatures from sourmash format. Currently only supports +mash dump format. + +For example, + +sourmash signature export filename.sig -o filename.sig.msh.json + +""" + from sourmash.cli.utils import add_ksize_arg, add_moltype_args def subparser(subparsers): - subparser = subparsers.add_parser('export') + subparser = subparsers.add_parser('export', description=__doc__, usage=usage) subparser.add_argument('filename') subparser.add_argument( '-q', '--quiet', action='store_true', @@ -18,7 +31,7 @@ def subparser(subparsers): '--md5', default=None, help='select the signature with this md5 as query' ) - add_ksize_arg(subparser, 31) + add_ksize_arg(subparser) add_moltype_args(subparser) diff --git a/src/sourmash/cli/sig/extract.py b/src/sourmash/cli/sig/extract.py index ee1c260185..a482526290 100644 --- a/src/sourmash/cli/sig/extract.py +++ b/src/sourmash/cli/sig/extract.py @@ -1,11 +1,48 @@ """extract one or more signatures""" +usage=""" + +### `sourmash signature extract` - extract signatures from a collection + +Extract the specified signature(s) from a collection of signatures. + +For example, + +sourmash signature extract *.sig -k 21 --dna -o extracted.sig + +will extract all nucleotide signatures calculated at k=21 from all +.sig files in the current directory. + +There are currently two other useful selectors for `extract`: you can specify +(part of) an md5sum, as output in the CSVs produced by `search` and `gather`; +and you can specify (part of) a name. + +For example, + +sourmash signature extract tests/test-data/*.fa.sig --md5 09a0869 + +will extract the signature from `47.fa.sig` which has an md5sum of +`09a08691ce52952152f0e866a59f6261`; and + +sourmash signature extract tests/test-data/*.fa.sig --name NC_009665 + +will extract the same signature, which has an accession number of +`NC_009665.1`. + +#### Using picklists with `sourmash sig extract` + +As of sourmash 4.2.0, `extract` also supports picklists, a feature by +which you can select signatures based on values in a CSV file. See +[the command line docs](https://sourmash.readthedocs.io/en/latest/command-line.html) for more information. + +""" + from sourmash.cli.utils import (add_moltype_args, add_ksize_arg, add_picklist_args, add_pattern_args) def subparser(subparsers): - subparser = subparsers.add_parser('extract') + subparser = subparsers.add_parser('extract', description=__doc__, usage=usage) subparser.add_argument('signatures', nargs='*') subparser.add_argument( '-q', '--quiet', action='store_true', @@ -32,7 +69,7 @@ def subparser(subparsers): '--from-file', help='a text file containing a list of files to load signatures from' ) - add_ksize_arg(subparser, 31) + add_ksize_arg(subparser) add_moltype_args(subparser) add_pattern_args(subparser) add_picklist_args(subparser) diff --git a/src/sourmash/cli/sig/filter.py b/src/sourmash/cli/sig/filter.py index 7cd7f34885..4f5f020d83 100644 --- a/src/sourmash/cli/sig/filter.py +++ b/src/sourmash/cli/sig/filter.py @@ -1,10 +1,31 @@ """filter k-mers on abundance""" +usage=""" + +### `sourmash signature filter` - remove hashes based on abundance + +Filter the hashes in the specified signature(s) by abundance, by either +`-m/--min-abundance` or `-M/--max-abundance` or both. Abundance selection is +inclusive, so `-m 2 -M 5` will select hashes with abundance greater than +or equal to 2, and less than or equal to 5. + +For example, + +sourmash signature -m 2 *.sig + + +will output new signatures containing only hashes that occur two or +more times in each signature. + +The `filter` command accepts the same selectors as `extract`. + +""" + from sourmash.cli.utils import add_moltype_args, add_ksize_arg def subparser(subparsers): - subparser = subparsers.add_parser('filter') + subparser = subparsers.add_parser('filter', description=__doc__, usage=usage) subparser.add_argument('signatures', nargs='+') subparser.add_argument( '-q', '--quiet', action='store_true', @@ -31,7 +52,7 @@ def subparser(subparsers): '-M', '--max-abundance', type=int, default=None, help='keep hashes <= this maximum abundance' ) - add_ksize_arg(subparser, 31) + add_ksize_arg(subparser) add_moltype_args(subparser) diff --git a/src/sourmash/cli/sig/flatten.py b/src/sourmash/cli/sig/flatten.py index 98d0990ac5..ca87b171c1 100644 --- a/src/sourmash/cli/sig/flatten.py +++ b/src/sourmash/cli/sig/flatten.py @@ -1,11 +1,29 @@ """remove abundances""" +usage=""" + +### `sourmash signature flatten` - remove abundance information from signatures + +Flatten the specified signature(s), removing abundances and setting +track_abundance to False. + +For example, + +sourmash signature flatten *.sig -o flattened.sig + +will remove all abundances from all of the .sig files in the current +directory. + +The `flatten` command accepts the same selectors as `extract`. + +""" + from sourmash.cli.utils import (add_moltype_args, add_ksize_arg, add_picklist_args) def subparser(subparsers): - subparser = subparsers.add_parser('flatten') + subparser = subparsers.add_parser('flatten', description=__doc__, usage=usage) subparser.add_argument('signatures', nargs='*') subparser.add_argument( '-q', '--quiet', action='store_true', @@ -32,7 +50,7 @@ def subparser(subparsers): '--from-file', help='a text file containing a list of files to load signatures from' ) - add_ksize_arg(subparser, 31) + add_ksize_arg(subparser) add_moltype_args(subparser) add_picklist_args(subparser) diff --git a/src/sourmash/cli/sig/grep.py b/src/sourmash/cli/sig/grep.py index aff0a44622..03d93299da 100644 --- a/src/sourmash/cli/sig/grep.py +++ b/src/sourmash/cli/sig/grep.py @@ -84,7 +84,7 @@ def subparser(subparsers): help="only output a count of discovered signatures; implies --silent", action='store_true' ) - add_ksize_arg(subparser, 31) + add_ksize_arg(subparser) add_moltype_args(subparser) add_picklist_args(subparser) diff --git a/src/sourmash/cli/sig/inflate.py b/src/sourmash/cli/sig/inflate.py index a467670d93..c5a247727a 100644 --- a/src/sourmash/cli/sig/inflate.py +++ b/src/sourmash/cli/sig/inflate.py @@ -20,7 +20,7 @@ def subparser(subparsers): '-f', '--force', action='store_true', help='try to load all files as signatures' ) - add_ksize_arg(subparser, 31) + add_ksize_arg(subparser) add_moltype_args(subparser) add_picklist_args(subparser) diff --git a/src/sourmash/cli/sig/ingest.py b/src/sourmash/cli/sig/ingest.py index b8a642236f..c5574aadda 100644 --- a/src/sourmash/cli/sig/ingest.py +++ b/src/sourmash/cli/sig/ingest.py @@ -14,7 +14,7 @@ def subparser(subparsers): help='suppress non-error output' ) subparser.add_argument( - '-o', '--output', metavar='FILE', + '-o', '--output', metavar='FILE', default='-', help='output signature to this file (default stdout)' ) diff --git a/src/sourmash/cli/sig/intersect.py b/src/sourmash/cli/sig/intersect.py index 542eba8a3b..4a5ea4db23 100644 --- a/src/sourmash/cli/sig/intersect.py +++ b/src/sourmash/cli/sig/intersect.py @@ -1,18 +1,40 @@ -"""intersect one or more signatures""" +"""intersect two or more signatures""" + +usage=""" + +### `sourmash signature intersect` - intersect two (or more) signatures + +Output the intersection of the hash values in multiple signature files. + +For example, + + +sourmash signature intersect file1.sig file2.sig file3.sig -o intersect.sig + +will output the intersection of all the hashes in those three files to +`intersect.sig`. + +The `intersect` command flattens all signatures, i.e. the abundances +in any signatures will be ignored and the output signature will have +`track_abundance` turned off. See `sourmash signature flatten` for more details. + +Note: `intersect` only creates one output file, with one signature in it. + +""" from sourmash.cli.utils import (add_moltype_args, add_ksize_arg, add_picklist_args) def subparser(subparsers): - subparser = subparsers.add_parser('intersect') + subparser = subparsers.add_parser('intersect', description=__doc__, usage=usage) subparser.add_argument('signatures', nargs='*') subparser.add_argument( '-q', '--quiet', action='store_true', help='suppress non-error output' ) subparser.add_argument( - '-o', '--output', metavar='FILE', + '-o', '--output', metavar='FILE', default='-', help='output signature to this file (default stdout)' ) subparser.add_argument( @@ -27,7 +49,7 @@ def subparser(subparsers): '--from-file', help='a text file containing a list of files to load signatures from' ) - add_ksize_arg(subparser, 31) + add_ksize_arg(subparser) add_moltype_args(subparser) add_picklist_args(subparser) diff --git a/src/sourmash/cli/sig/kmers.py b/src/sourmash/cli/sig/kmers.py index a51c27f8c9..08863f33c9 100644 --- a/src/sourmash/cli/sig/kmers.py +++ b/src/sourmash/cli/sig/kmers.py @@ -1,11 +1,59 @@ """show k-mers/sequences matching the signature hashes""" +usage=""" + +### `sourmash signature kmers` - extract k-mers and/or sequences that match to signatures + +Given one or more compatible sketches and some sequence files, extract +the k-mers and/or sequences corresponding to the hash values in the +sketch. Because the sourmash hash function is one-way, this requires +FASTA or FASTQ sequence files in addition to the sketch. + +For example, + +sourmash sig kmers --signatures sig1.sig --sequences seqfile.fasta \ + --save-sequences matches.fasta --save-kmers kmer-matches.csv + +will search `seqfile.fasta` for matching sequences and k-mers, +and produce two files. The file `matches.fasta` will contain FASTA +sequences that match the hashes in the input signature, while the +file `kmer-matches.csv` provides the matching k-mers and hash values, +together with their originating filename and sequence name. + +If the sketch is a protein sketch (protein, dayhoff, or hp), then +the input sequences are assumed to be protein. To search DNA sequences +for translated protein hashes, provide the `--translate` flag to `sig kmers`. + +`--save-sequences` and `--save-kmers` are both optional. If neither are +given, basic statistics on k-mer matching are given. + +Please note that `--save-kmers` can be very slow on large files! + +The input sketches are the source of the input hashes. So, for example, +If `--scaled=1` sketches are provided, `sig kmers` can be used to +yield all the k-mers and their matching hashes. Likewise, if the +sketch is built from the intersection of two other sketches, only +the k-mers and hash values present in both sketches will be used. + +Likewise, the input sequences are used for matching; they do not need +to be the same sequences that were used to create the sketches. +Input sequences can be in FASTA or FASTQ format, and either flat text +or compressed with gzip or bzip2; formats are auto-detected. + +By default, `sig kmers` ignores bad k-mers (e.g. non-ACGT characters +in DNA). If `--check-sequence` is provided, `sig kmers` will error +exit on the first bad k-mer. If `--check-sequence --force` is provided, +`sig kmers` will provide error messages (and skip bad sequences), but +will continue processing input sequences. + +""" + from sourmash.cli.utils import (add_moltype_args, add_ksize_arg, add_picklist_args) def subparser(subparsers): - subparser = subparsers.add_parser('kmers') + subparser = subparsers.add_parser('kmers', description=__doc__, usage=usage) subparser.add_argument('--signatures', nargs='*', default=[]) subparser.add_argument( '-q', '--quiet', action='store_true', @@ -19,7 +67,7 @@ def subparser(subparsers): '--from-file', help='a text file containing a list of files to load signatures from' ) - add_ksize_arg(subparser, 31) + add_ksize_arg(subparser) add_moltype_args(subparser) add_picklist_args(subparser) diff --git a/src/sourmash/cli/sig/merge.py b/src/sourmash/cli/sig/merge.py index 34cd7ac658..6de8b77d16 100644 --- a/src/sourmash/cli/sig/merge.py +++ b/src/sourmash/cli/sig/merge.py @@ -1,18 +1,42 @@ """merge one or more signatures""" +usage=""" + +### `sourmash signature merge` - merge two or more signatures into one + +Merge two (or more) signatures. + +For example, + +sourmash signature merge file1.sig file2.sig -o merged.sig + +will output the union of all the hashes in `file1.sig` and `file2.sig` +to `merged.sig`. + +All of the signatures passed to merge must either have been created +with `-p abund`, or not. If they have `track_abundance` on, +then the merged signature will have the sum of all abundances across +the individual signatures. The `--flatten` flag will override this +behavior and allow merging of mixtures by removing all abundances. + +Note: `merge` only creates one output file, with one signature in it, +in the JSON `.sig` format. + +""" + from sourmash.cli.utils import (add_moltype_args, add_ksize_arg, add_picklist_args) def subparser(subparsers): - subparser = subparsers.add_parser('merge') + subparser = subparsers.add_parser('merge', description=__doc__, usage=usage) subparser.add_argument('signatures', nargs='*') subparser.add_argument( '-q', '--quiet', action='store_true', help='suppress non-error output' ) subparser.add_argument( - '-o', '--output', metavar='FILE', + '-o', '--output', metavar='FILE', default='-', help='output signature to this file (default stdout)' ) subparser.add_argument( @@ -31,7 +55,7 @@ def subparser(subparsers): '--from-file', help='a text file containing a list of files to load signatures from' ) - add_ksize_arg(subparser, 31) + add_ksize_arg(subparser) add_moltype_args(subparser) add_picklist_args(subparser) diff --git a/src/sourmash/cli/sig/overlap.py b/src/sourmash/cli/sig/overlap.py index ab4fd9aec5..373336253c 100644 --- a/src/sourmash/cli/sig/overlap.py +++ b/src/sourmash/cli/sig/overlap.py @@ -1,17 +1,41 @@ """see detailed comparison of signatures""" +usage=""" + +### `sourmash signature overlap` - detailed comparison of two signatures' overlap + +Display a detailed comparison of two signatures. This calculates the +Jaccard similarity (as in `sourmash compare` or `sourmash search`) and +the Jaccard containment in both directions (as with `--containment`). +It also displays the number of hash values in the union and +intersection of the two signatures, as well as the number of disjoint +hash values in each signature. + +This command has two uses - first, it is helpful for understanding how +similarity and containment are calculated, and second, it is useful for +analyzing signatures with very small overlaps, where the similarity +and/or containment might be very close to zero. + +For example, + +sourmash signature overlap file1.sig file2.sig + +will display the detailed comparison of `file1.sig` and `file2.sig`. + +""" + from sourmash.cli.utils import add_moltype_args, add_ksize_arg def subparser(subparsers): - subparser = subparsers.add_parser('overlap') + subparser = subparsers.add_parser('overlap', description=__doc__, usage=usage) subparser.add_argument('signature1') subparser.add_argument('signature2') subparser.add_argument( '-q', '--quiet', action='store_true', help='suppress non-error output' ) - add_ksize_arg(subparser, 31) + add_ksize_arg(subparser) add_moltype_args(subparser) diff --git a/src/sourmash/cli/sig/rename.py b/src/sourmash/cli/sig/rename.py index eb59bdeb3c..2b360fa8d3 100644 --- a/src/sourmash/cli/sig/rename.py +++ b/src/sourmash/cli/sig/rename.py @@ -1,11 +1,28 @@ """rename signature""" +usage=""" + +### `sourmash signature rename` - rename a signature + +Rename the display name for one or more signatures - this is the name +output for matches in `compare`, `search`, `gather`, etc. + +For example, + +sourmash signature rename file1.sig "new name" -o renamed.sig + +will place a renamed copy of the hashes in `file1.sig` in the file +`renamed.sig`. If you provide multiple signatures, all will be renamed +to the same name. + +""" + from sourmash.cli.utils import (add_moltype_args, add_ksize_arg, add_picklist_args, add_pattern_args) def subparser(subparsers): - subparser = subparsers.add_parser('rename') + subparser = subparsers.add_parser('rename', description=__doc__, usage=usage) subparser.add_argument('signatures', nargs='*') subparser.add_argument('name') subparser.add_argument( @@ -29,7 +46,7 @@ def subparser(subparsers): '--from-file', help='a text file containing a list of files to load signatures from' ) - add_ksize_arg(subparser, 31) + add_ksize_arg(subparser) add_moltype_args(subparser) add_pattern_args(subparser) add_picklist_args(subparser) diff --git a/src/sourmash/cli/sig/split.py b/src/sourmash/cli/sig/split.py index e4fb521c53..e4587b3e0f 100644 --- a/src/sourmash/cli/sig/split.py +++ b/src/sourmash/cli/sig/split.py @@ -1,11 +1,47 @@ -"""concatenate signature files""" +"""split signature files""" + +usage=""" + +### `sourmash signature split` - split signatures into individual files + +Split each signature in the input file(s) into individual files, with +standardized names. + +For example, + +sourmash signature split tests/test-data/2.fa.sig + +will create 3 files, + +`f372e478.k=21.scaled=1000.DNA.dup=0.2.fa.sig`, +`f3a90d4e.k=31.scaled=1000.DNA.dup=0.2.fa.sig`, and +`43f3b48e.k=51.scaled=1000.DNA.dup=0.2.fa.sig`, representing the three +different DNA signatures at different ksizes created from the input file +`2.fa`. + +The format of the names of the output files is standardized and stable +for major versions of sourmash: currently, they are period-separated +with fields: + +* `md5sum` - a unique hash value based on the contents of the signature. +* `k=` - k-mer size. +* `scaled=` or `num=` - scaled or num value for MinHash. +* `` - the molecule type (DNA, protein, dayhoff, or hp) +* `dup=` - a non-negative integer that prevents duplicate signatures from colliding. +* `basename` - basename of first input file used to create signature; if none provided, or stdin, this is `none`. + +If `--outdir` is specified, all of the signatures are placed in outdir. + +Note: `split` only saves files in the JSON `.sig` format. + +""" from sourmash.cli.utils import (add_moltype_args, add_ksize_arg, add_picklist_args) def subparser(subparsers): - subparser = subparsers.add_parser('split') + subparser = subparsers.add_parser('split', description=__doc__, usage=usage) subparser.add_argument('signatures', nargs='*') subparser.add_argument( '-q', '--quiet', action='store_true', @@ -23,7 +59,11 @@ def subparser(subparsers): '--from-file', help='a text file containing a list of files to load signatures from' ) - add_ksize_arg(subparser, 31) + subparser.add_argument( + '-E', '--extension', type=str, default='.sig', + help="write files with this extension ('.sig' by default)" + ) + add_ksize_arg(subparser) add_moltype_args(subparser) add_picklist_args(subparser) diff --git a/src/sourmash/cli/sig/subtract.py b/src/sourmash/cli/sig/subtract.py index feeec38d69..118d91fe41 100644 --- a/src/sourmash/cli/sig/subtract.py +++ b/src/sourmash/cli/sig/subtract.py @@ -1,10 +1,32 @@ """subtract one or more signatures""" +usage=""" + +### `sourmash signature subtract` - subtract other signatures from a signature + +Subtract all of the hash values from one signature that are in one or more +of the others. + +For example, + + +sourmash signature subtract file1.sig file2.sig file3.sig -o subtracted.sig + +will subtract all of the hashes in `file2.sig` and `file3.sig` from +`file1.sig`, and save the new signature to `subtracted.sig`. + +To use `subtract` on signatures calculated with +`-p abund`, you must specify `--flatten`. + +Note: `subtract` only creates one output file, with one signature in it. + +""" + from sourmash.cli.utils import (add_moltype_args, add_ksize_arg) def subparser(subparsers): - subparser = subparsers.add_parser('subtract') + subparser = subparsers.add_parser('subtract', description=__doc__, usage=usage) subparser.add_argument('signature_from') subparser.add_argument('subtraction_sigs', nargs='+') subparser.add_argument( @@ -12,7 +34,7 @@ def subparser(subparsers): help='suppress non-error output' ) subparser.add_argument( - '-o', '--output', metavar='FILE', + '-o', '--output', metavar='FILE', default='-', help='output signature to this file (default stdout)' ) subparser.add_argument( @@ -23,7 +45,7 @@ def subparser(subparsers): '-A', '--abundances-from', metavar='FILE', help='intersect with & take abundances from this signature' ) - add_ksize_arg(subparser, 31) + add_ksize_arg(subparser) add_moltype_args(subparser) diff --git a/src/sourmash/cli/sketch/fromfile.py b/src/sourmash/cli/sketch/fromfile.py index 84291b2931..08a3e44661 100644 --- a/src/sourmash/cli/sketch/fromfile.py +++ b/src/sourmash/cli/sketch/fromfile.py @@ -71,6 +71,10 @@ def subparser(subparsers): '--output-manifest-matching', help='output a manifest file of already-existing signatures' ) + file_args.add_argument( + '--report-duplicated', action='store_true', + help='report duplicated names' + ) def main(args): diff --git a/src/sourmash/cli/tax/__init__.py b/src/sourmash/cli/tax/__init__.py index 2a9b5b0302..b8bf95f8d8 100644 --- a/src/sourmash/cli/tax/__init__.py +++ b/src/sourmash/cli/tax/__init__.py @@ -8,6 +8,9 @@ from . import genome from . import annotate from . import prepare +from . import grep +from . import summarize + from ..utils import command_list from argparse import SUPPRESS, RawDescriptionHelpFormatter import os diff --git a/src/sourmash/cli/tax/annotate.py b/src/sourmash/cli/tax/annotate.py index f25a532554..501a02fd58 100644 --- a/src/sourmash/cli/tax/annotate.py +++ b/src/sourmash/cli/tax/annotate.py @@ -7,7 +7,7 @@ The 'tax annotate' command reads in gather results CSVs and annotates them with taxonomic information. -By default, `tax annotate` produces a gather CSV with an additional `lineage` +By default, 'tax annotate' produces a gather CSV with an additional 'lineage' column containing the taxonomic information for each database match. Please see the 'tax annotate' documentation for more details: @@ -23,7 +23,7 @@ def subparser(subparsers): aliases=['annotate'], usage=usage) subparser.add_argument( - '-g', '--gather-csv', nargs='*', default = [], + '-g', '--gather-csv', nargs='*', default = [], action='extend', help='CSV output files from sourmash gather' ) subparser.add_argument( @@ -36,7 +36,7 @@ def subparser(subparsers): ) subparser.add_argument( '-t', '--taxonomy-csv', '--taxonomy', metavar='FILE', - nargs="+", required=True, + nargs='*', required=True, action="extend", help='database lineages CSV' ) subparser.add_argument( @@ -59,9 +59,13 @@ def subparser(subparsers): '-f', '--force', action = 'store_true', help='continue past errors in file and taxonomy loading', ) + subparser.add_argument( + '--lins', '--lin-taxonomy', action='store_true', default=False, + help='use LIN taxonomy in place of standard taxonomic ranks. Note that the taxonomy CSV must contain LIN lineage information.' + ) def main(args): import sourmash if not args.gather_csv and not args.from_file: - raise ValueError(f"No gather CSVs found! Please input via `-g` or `--from-file`.") + raise ValueError(f"No gather CSVs found! Please input via '-g' or '--from-file'.") return sourmash.tax.__main__.annotate(args) diff --git a/src/sourmash/cli/tax/genome.py b/src/sourmash/cli/tax/genome.py index f37fb35c8a..3f3ee41578 100644 --- a/src/sourmash/cli/tax/genome.py +++ b/src/sourmash/cli/tax/genome.py @@ -7,21 +7,24 @@ The 'tax genome' command reads in genome gather result CSVs and reports likely classification for each query genome. -By default, classification uses a containment threshold of 0.1, meaning at least -10 percent of the query was covered by matches with the reported taxonomic rank and lineage. -You can specify an alternate classification threshold or force classification by -taxonomic rank instead, e.g. at species or genus-level. +By default, classification uses a containment threshold of 0.1, +meaning at least 10 percent of the query was covered by matches with +the reported taxonomic rank and lineage. You can specify an alternate +classification threshold or force classification by taxonomic rank +instead, e.g. at species or genus-level. The default output format consists of five columns, - `query_name,status,rank,fraction,lineage`, where `fraction` is the fraction - of the query matched to the reported rank and lineage. The `status` column + 'query_name,status,rank,fraction,lineage', where 'fraction' is the fraction + of the query matched to the reported rank and lineage. The 'status' column provides additional information on the classification, and can be: - - `match` - this query was classified - - `nomatch`- this query could not be classified - - `below_threshold` - this query was classified at the specified rank, + - 'match' - this query was classified + - 'nomatch'- this query could not be classified + - 'below_threshold' - this query was classified at the specified rank, but the query fraction matched was below the containment threshold -Optionally, you can report classifications in `krona` format, but note +Use '-F human' to display human-readable output instead. + +Optionally, you can report classifications in 'krona' format, but note that this forces classification by rank, rather than containment threshold. Please see the 'tax genome' documentation for more details: @@ -31,14 +34,14 @@ import argparse import sourmash from sourmash.logging import notify, print_results, error -from sourmash.cli.utils import add_tax_threshold_arg +from sourmash.cli.utils import add_tax_threshold_arg, check_rank, check_tax_outputs, add_rank_arg def subparser(subparsers): subparser = subparsers.add_parser('genome', aliases=['classify'], usage=usage) subparser.add_argument( - '-g', '--gather-csv', nargs='*', default = [], + '-g', '--gather-csv', action='extend', nargs='*', default = [], help='CSVs output by sourmash gather for this sample' ) subparser.add_argument( @@ -51,7 +54,7 @@ def subparser(subparsers): ) subparser.add_argument( '-t', '--taxonomy-csv', '--taxonomy', metavar='FILE', - nargs='+', required=True, + nargs='*', required=True, action='extend', help='database lineages CSV' ) subparser.add_argument( @@ -62,10 +65,6 @@ def subparser(subparsers): '--output-dir', default= "", help='directory for output files' ) - subparser.add_argument( - '-r', '--rank', choices=['strain', 'species', 'genus', 'family', 'order', 'class', 'phylum', 'superkingdom'], - help='Summarize genome taxonomy at this rank and above. Note that the taxonomy CSV must contain lineage information at this rank.' - ) subparser.add_argument( '--keep-full-identifiers', action='store_true', help='do not split identifiers on whitespace' @@ -79,24 +78,37 @@ def subparser(subparsers): help='fail quickly if taxonomy is not available for an identifier', ) subparser.add_argument( - '--output-format', default=['csv_summary'], nargs='+', choices=["csv_summary", "krona"], + '-F', '--output-format', default=[], nargs='*', action='extend', + choices=["csv_summary", "krona", "human", "lineage_csv"], help='choose output format(s)', ) subparser.add_argument( '-f', '--force', action = 'store_true', help='continue past survivable errors in loading taxonomy database or gather results', ) + subparser.add_argument( + '--lins', '--lin-taxonomy', action='store_true', default=False, + help="use LIN taxonomy in place of standard taxonomic ranks. Note that the taxonomy CSV must contain 'lin' lineage information." + ) + subparser.add_argument( + '--lingroup', '--lingroups', metavar='FILE', default=None, + help="CSV containing 'name', 'lin' columns, where 'lin' is the lingroup prefix. Will restrict classification to these groups." + ) add_tax_threshold_arg(subparser, 0.1) + add_rank_arg(subparser) def main(args): import sourmash - if not args.gather_csv and not args.from_file: - raise ValueError(f"No gather CSVs found! Please input via `-g` or `--from-file`.") - if len(args.output_format) > 1: - if args.output_base == "-": - raise TypeError(f"Writing to stdout is incompatible with multiple output formats {args.output_format}") - if not args.rank: - if any(x in ["krona"] for x in args.output_format): - raise ValueError(f"Rank (--rank) is required for krona output format.") + try: + if not args.gather_csv and not args.from_file: + raise ValueError(f"No gather CSVs found! Please input via '-g' or '--from-file'.") + if args.rank: + args.rank = check_rank(args) + args.output_format = check_tax_outputs(args, rank_required = ['krona']) + + except ValueError as exc: + error(f"ERROR: {str(exc)}") + import sys; sys.exit(-1) + return sourmash.tax.__main__.genome(args) diff --git a/src/sourmash/cli/tax/grep.py b/src/sourmash/cli/tax/grep.py new file mode 100644 index 0000000000..003c152dc1 --- /dev/null +++ b/src/sourmash/cli/tax/grep.py @@ -0,0 +1,72 @@ +"""search taxonomies and output picklists..""" + +usage=""" + + sourmash tax grep --taxonomy-csv [ ... ] + +`sourmash tax grep` searches taxonomies for matching strings, +optionally restricting the string search to a specific taxonomic rank. +It creates new files containing matching taxonomic entries; these new +files can serve as taxonomies and can also be used as picklists. + +Please see the 'tax grep' documentation for more details: + https://sourmash.readthedocs.io/en/latest/command-line.html#sourmash-tax-grep-subset-taxonomies-and-create-picklists-based-on-taxonomy-string-matches +""" + +import sourmash +from sourmash.logging import notify, print_results, error + + +def subparser(subparsers): + subparser = subparsers.add_parser('grep', usage=usage) + subparser.add_argument('pattern') + subparser.add_argument('-r', '--rank', + help="search only this rank", + choices=['superkingdom', + 'phylum', + 'class', + 'order', + 'family', + 'genus', + 'species']) + subparser.add_argument( + '-v', '--invert-match', + help="select non-matching lineages", + action="store_true" + ) + subparser.add_argument( + '-i', '--ignore-case', + help="ignore case distinctions (search lower and upper case both)", + action="store_true" + ) + subparser.add_argument( + '--silent', '--no-picklist-output', + help="do not output picklist", + action='store_true', + ) + subparser.add_argument( + '-c', '--count', + help="only output a count of discovered lineages; implies --silent", + action='store_true' + ) + subparser.add_argument( + '-q', '--quiet', action='store_true', + help='suppress non-error output' + ) + subparser.add_argument( + '-t', '--taxonomy-csv', '--taxonomy', metavar='FILE', + nargs="+", required=True, action="extend", + help='database lineages' + ) + subparser.add_argument( + '-o', '--output', default='-', + help='output file (defaults to stdout)', + ) + subparser.add_argument( + '-f', '--force', action = 'store_true', + help='continue past errors in file and taxonomy loading', + ) + +def main(args): + import sourmash + return sourmash.tax.__main__.grep(args) diff --git a/src/sourmash/cli/tax/metagenome.py b/src/sourmash/cli/tax/metagenome.py index 4eb6b352db..1e3cd50313 100644 --- a/src/sourmash/cli/tax/metagenome.py +++ b/src/sourmash/cli/tax/metagenome.py @@ -8,12 +8,14 @@ summarizes by taxonomic lineage. The default output format consists of four columns, - `query_name,rank,fraction,lineage`, where `fraction` is the fraction + 'query_name,rank,fraction,lineage', where 'fraction' is the fraction of the query matched to that reported rank and lineage. The summarization is reported for each taxonomic rank. Alternatively, you can output results at a specific rank (e.g. species) -in `krona` or `lineage_summary` formats. +in 'krona', 'lineage_summary', and 'human' formats. + +Use '-F human' to display human-readable output. Please see the 'tax metagenome' documentation for more details: https://sourmash.readthedocs.io/en/latest/command-line.html#sourmash-tax-metagenome-summarize-metagenome-content-from-gather-results @@ -21,14 +23,15 @@ import sourmash from sourmash.logging import notify, print_results, error +from sourmash.cli.utils import add_rank_arg, check_rank, check_tax_outputs + def subparser(subparsers): subparser = subparsers.add_parser('metagenome', - aliases=['summarize'], usage=usage) subparser.add_argument( - '-g', '--gather-csv', nargs='*', default = [], + '-g', '--gather-csv', action="extend", nargs='*', default = [], help='CSVs from sourmash gather' ) subparser.add_argument( @@ -49,7 +52,7 @@ def subparser(subparsers): ) subparser.add_argument( '-t', '--taxonomy-csv', '--taxonomy', metavar='FILE', - nargs='+', required=True, + action="extend", nargs='+', required=True, help='database lineages CSV' ) subparser.add_argument( @@ -65,26 +68,35 @@ def subparser(subparsers): help='fail quickly if taxonomy is not available for an identifier', ) subparser.add_argument( - '--output-format', default=['csv_summary'], nargs='+', choices=["csv_summary", "krona", "lineage_summary"], + '-F', '--output-format', default=[], nargs='*', action="extend", + choices=["human", "csv_summary", "krona", "lineage_summary", "kreport", "lingroup", "bioboxes"], help='choose output format(s)', ) - subparser.add_argument( - '-r', '--rank', choices=['strain','species', 'genus', 'family', 'order', 'class', 'phylum', 'superkingdom'], - help='For non-default output formats: Summarize genome taxonomy at this rank and above. Note that the taxonomy CSV must contain lineage information at this rank.' - ) subparser.add_argument( '-f', '--force', action = 'store_true', help='continue past errors in taxonomy database loading', ) + subparser.add_argument( + '--lins', '--lin-taxonomy', action='store_true', default=False, + help="use LIN taxonomy in place of standard taxonomic ranks. Note that the taxonomy CSV must contain 'lin' lineage information." + ) + subparser.add_argument( + '--lingroup', '--lingroups', metavar='FILE', default=None, + help="CSV containing 'name', 'lin' columns, where 'lin' is the lingroup prefix. Will produce a 'lingroup' report containing taxonomic summarization for each group." + ) + add_rank_arg(subparser) def main(args): import sourmash - if not args.gather_csv and not args.from_file: - raise ValueError(f"No gather CSVs found! Please input via `-g` or `--from-file`.") - if len(args.output_format) > 1: - if args.output_base == "-": - raise TypeError(f"Writing to stdout is incompatible with multiple output formats {args.output_format}") - if not args.rank: - if any(x in ["krona", "lineage_summary"] for x in args.output_format): - raise ValueError(f"Rank (--rank) is required for krona and lineage_summary output formats.") + try: + if not args.gather_csv and not args.from_file: + raise ValueError(f"No gather CSVs found! Please input via '-g' or '--from-file'.") + if args.rank: + args.rank = check_rank(args) + args.output_format = check_tax_outputs(args, rank_required = ['krona', 'lineage_summary'], incompatible_with_lins = ['bioboxes', 'kreport'], use_lingroup_format=True) + + except ValueError as exc: + error(f"ERROR: {str(exc)}") + import sys; sys.exit(-1) + return sourmash.tax.__main__.metagenome(args) diff --git a/src/sourmash/cli/tax/prepare.py b/src/sourmash/cli/tax/prepare.py index 01d978c73e..de2e58521b 100644 --- a/src/sourmash/cli/tax/prepare.py +++ b/src/sourmash/cli/tax/prepare.py @@ -25,7 +25,7 @@ def subparser(subparsers): ) subparser.add_argument( '-t', '--taxonomy-csv', '--taxonomy', metavar='FILE', - nargs="+", required=True, + nargs="+", required=True, action="extend", help='database lineages' ) subparser.add_argument( diff --git a/src/sourmash/cli/tax/summarize.py b/src/sourmash/cli/tax/summarize.py new file mode 100644 index 0000000000..06a109e95c --- /dev/null +++ b/src/sourmash/cli/tax/summarize.py @@ -0,0 +1,56 @@ +"""summarize taxonomy/lineage information""" + +usage=""" + + sourmash tax summarize [ ... ] + +The 'tax summarize' command reads in one or more taxonomy databases +or lineage files (produced by 'tax annotate'), combines them, +and produces a human readable summary. + +Please see the 'tax summarize' documentation for more details: + https://sourmash.readthedocs.io/en/latest/command-line.html#command-line.html#sourmash-tax-summarize-print-summary-information-for-lineage-spreadsheets-or-taxonomy-databases + +""" + +import sourmash +from sourmash.logging import notify, print_results, error + + +def subparser(subparsers): + subparser = subparsers.add_parser('summarize', + usage=usage) + subparser.add_argument( + '-q', '--quiet', action='store_true', + help='suppress non-error output' + ) + subparser.add_argument( + 'taxonomy_files', + metavar='FILE', + nargs="+", action="extend", + help='database lineages' + ) + subparser.add_argument( + '-o', '--output-lineage-information', + help='output a CSV file containing individual lineage counts', + ) + subparser.add_argument( + '--keep-full-identifiers', action='store_true', + help='do not split identifiers on whitespace' + ) + subparser.add_argument( + '--keep-identifier-versions', action='store_true', + help='after splitting identifiers, do not remove accession versions' + ) + subparser.add_argument( + '-f', '--force', action = 'store_true', + help='continue past errors in file and taxonomy loading', + ) + subparser.add_argument( + '--lins', '--lin-taxonomy', action='store_true', default=False, + help='use LIN taxonomy in place of standard taxonomic ranks.' + ) + +def main(args): + import sourmash + return sourmash.tax.__main__.summarize(args) diff --git a/src/sourmash/cli/utils.py b/src/sourmash/cli/utils.py index 1725518747..e0d8975b09 100644 --- a/src/sourmash/cli/utils.py +++ b/src/sourmash/cli/utils.py @@ -47,10 +47,16 @@ def add_construct_moltype_args(parser): parser.set_defaults(dna=True) -def add_ksize_arg(parser, default=31): +def add_ksize_arg(parser, *, default=None): + "Add -k/--ksize to argparse parsers, with specified default." + if default: + message = f"k-mer size to select; default={default}" + else: + message = f"k-mer size to select; no default." + parser.add_argument( - '-k', '--ksize', metavar='K', default=None, type=int, - help='k-mer size; default={d}'.format(d=default) + '-k', '--ksize', metavar='K', default=default, type=int, + help=message, ) #https://stackoverflow.com/questions/55324449/how-to-specify-a-minimum-or-maximum-float-value-with-argparse#55410582 @@ -67,10 +73,14 @@ def range_limited_float_type(arg): return f -def add_tax_threshold_arg(parser, default=0.1): +def add_tax_threshold_arg(parser, containment_default=0.1, ani_default=None): + parser.add_argument( + '--containment-threshold', default=containment_default, type=range_limited_float_type, + help=f'minimum containment threshold for classification; default={containment_default}', + ) parser.add_argument( - '--containment-threshold', default=default, type=range_limited_float_type, - help=f'minimum containment threshold for classification; default={default}' + '--ani-threshold', '--aai-threshold', default=ani_default, type=range_limited_float_type, + help=f'minimum ANI threshold (nucleotide gather) or AAI threshold (protein gather) for classification; default={ani_default}', ) @@ -113,7 +123,7 @@ def command_list(dirpath): def add_scaled_arg(parser, default=None): parser.add_argument( '--scaled', metavar='FLOAT', type=check_scaled_bounds, - help='scaled value should be between 100 and 1e6' + help='downsample to this scaled; value should be between 100 and 1e6' ) @@ -122,3 +132,58 @@ def add_num_arg(parser, default=0): '-n', '--num-hashes', '--num', metavar='N', type=check_num_bounds, default=default, help='num value should be between 50 and 50000' ) + + +def check_rank(args): + """ Check '--rank'/'--position'/'--lin-position' argument matches selected taxonomy.""" + standard_ranks =['strain', 'species', 'genus', 'family', 'order', 'class', 'phylum', 'superkingdom'] + if args.lins: + if args.rank.isdigit(): + return str(args.rank) + raise argparse.ArgumentTypeError(f"Invalid '--rank'/'--position' input: '{args.rank}'. '--lins' is specified. Rank must be an integer corresponding to a LIN position.") + elif args.rank in standard_ranks: + return args.rank + else: + raise argparse.ArgumentTypeError(f"Invalid '--rank'/'--position' input: '{args.rank}'. Please choose: 'strain', 'species', 'genus', 'family', 'order', 'class', 'phylum', 'superkingdom'") + + +def add_rank_arg(parser): + parser.add_argument( + '-r', '--rank', + '--position', '--lin-position', + help="For non-default output formats. Classify to this rank (tax genome) or summarize taxonomy at this rank and above (tax metagenome). \ + Note that the taxonomy CSV must contain lineage information at this rank, and that LIN positions start at 0. \ + Choices: 'strain', 'species', 'genus', 'family', 'order', 'class', 'phylum', 'superkingdom' or an integer LIN position" + ) + + +def check_tax_outputs(args, rank_required = ["krona"], incompatible_with_lins = None, use_lingroup_format=False): + "Handle ouput format combinations" + # check that rank is passed for formats requiring rank. + if not args.rank: + if any(x in rank_required for x in args.output_format): + raise ValueError(f"Rank (--rank) is required for {', '.join(rank_required)} output formats.") + + if args.lins: + # check for outputs incompatible with lins + if incompatible_with_lins: + if any(x in args.output_format for x in incompatible_with_lins): + raise ValueError(f"The following outputs are incompatible with '--lins': : {', '.join(incompatible_with_lins)}") + # check that lingroup file exists if needed + if args.lingroup: + if use_lingroup_format and "lingroup" not in args.output_format: + args.output_format.append("lingroup") + elif "lingroup" in args.output_format: + raise ValueError(f"Must provide lingroup csv via '--lingroup' in order to output a lingroup report.") + elif args.lingroup or "lingroup" in args.output_format: + raise ValueError(f"Must enable LIN taxonomy via '--lins' in order to use lingroups.") + + # check that only one output format is specified if writing to stdout + if len(args.output_format) > 1: + if args.output_base == "-": + raise ValueError(f"Writing to stdout is incompatible with multiple output formats {args.output_format}") + elif not args.output_format: + # change to "human" for 5.0 + args.output_format = ["csv_summary"] + + return args.output_format diff --git a/src/sourmash/cli/watch.py b/src/sourmash/cli/watch.py index 16b8f43f71..7828d376e2 100644 --- a/src/sourmash/cli/watch.py +++ b/src/sourmash/cli/watch.py @@ -28,7 +28,7 @@ def subparser(subparsers): '--name', type=str, default='stdin', help='name to use for generated signature' ) - add_ksize_arg(subparser, 31) + add_ksize_arg(subparser) add_num_arg(subparser, 500) def main(args): diff --git a/src/sourmash/command_compute.py b/src/sourmash/command_compute.py index b028664b53..efd53b5d1d 100644 --- a/src/sourmash/command_compute.py +++ b/src/sourmash/command_compute.py @@ -130,7 +130,7 @@ def compute(args): _compute_individual(args, signatures_factory) -class _signatures_for_compute_factory(object): +class _signatures_for_compute_factory: "Build signatures on demand, based on args input to 'compute'." def __init__(self, args): self.args = args diff --git a/src/sourmash/command_sketch.py b/src/sourmash/command_sketch.py index db8aa5a87f..f79e3a5fc8 100644 --- a/src/sourmash/command_sketch.py +++ b/src/sourmash/command_sketch.py @@ -85,7 +85,7 @@ def _parse_params_str(params_str): return moltype, params -class _signatures_for_sketch_factory(object): +class _signatures_for_sketch_factory: "Build sigs on demand, based on args input to 'sketch'." def __init__(self, params_str_list, default_moltype): # first, set up defaults per-moltype @@ -424,11 +424,10 @@ def fromfile(args): skipped_sigs = 0 n_missing_name = 0 n_duplicate_name = 0 + duplicate_names = set() for csvfile in args.csvs: - with open(csvfile, newline="") as fp: - r = csv.DictReader(fp) - + with sourmash_args.FileInputCSV(csvfile) as r: for row in r: name = row['name'] if not name: @@ -441,11 +440,14 @@ def fromfile(args): if name in all_names: n_duplicate_name += 1 + duplicate_names.add(name) else: all_names[name] = (genome, proteome) fail_exit = False if n_duplicate_name: + if args.report_duplicated: + notify("duplicated:\n" + '\n'.join(sorted(duplicate_names))) error(f"** ERROR: {n_duplicate_name} entries have duplicate 'name' records. Exiting!") fail_exit = True diff --git a/src/sourmash/commands.py b/src/sourmash/commands.py index 4e589d1287..3feb8c7964 100644 --- a/src/sourmash/commands.py +++ b/src/sourmash/commands.py @@ -5,6 +5,7 @@ import os import os.path import sys +import shutil import screed from .compare import (compare_all_pairs, compare_serial_containment, @@ -21,6 +22,12 @@ WATERMARK_SIZE = 10000 +def _get_screen_width(): + # default fallback is 80x24 + (col, rows) = shutil.get_terminal_size() + + return col + def compare(args): "Compare multiple signature files and create a distance matrix." @@ -95,7 +102,7 @@ def compare(args): # complain if it's not all one or the other if is_scaled != is_scaled_2: - error('cannot mix scaled signatures with bounded signatures') + error('ERROR: cannot mix scaled signatures with num signatures') sys.exit(-1) is_containment = False @@ -127,18 +134,35 @@ def compare(args): if track_abundances: notify('NOTE: --containment, --max-containment, --avg-containment, and --estimate-ani ignore signature abundances.') - # if using --scaled, downsample appropriately + # if using scaled sketches or --scaled, downsample to common max scaled. printed_scaled_msg = False if is_scaled: max_scaled = max(s.minhash.scaled for s in siglist) + if args.scaled: + args.scaled = int(args.scaled) + + max_scaled = max(max_scaled, args.scaled) + if max_scaled > args.scaled: + notify(f"WARNING: --scaled specified {args.scaled}, but max scaled of sketches is {max_scaled}") + notify(f"WARNING: continuing with scaled value of {max_scaled}.") + + new_siglist = [] for s in siglist: if not size_may_be_inaccurate and not s.minhash.size_is_accurate(): size_may_be_inaccurate = True if s.minhash.scaled != max_scaled: if not printed_scaled_msg: - notify(f'downsampling to scaled value of {format(max_scaled)}') + notify(f'NOTE: downsampling to scaled value of {format(max_scaled)}') printed_scaled_msg = True - s.minhash = s.minhash.downsample(scaled=max_scaled) + with s.update() as s: + s.minhash = s.minhash.downsample(scaled=max_scaled) + new_siglist.append(s) + else: + new_siglist.append(s) + siglist = new_siglist + elif args.scaled is not None: + error("ERROR: cannot specify --scaled with non-scaled signatures.") + sys.exit(-1) if len(siglist) == 0: error('no signatures!') @@ -162,16 +186,26 @@ def compare(args): similarity = compare_all_pairs(siglist, args.ignore_abundance, n_jobs=args.processes, return_ani=return_ani) + # if distance matrix desired, switch to 1-similarity + if args.distance_matrix: + matrix = 1 - similarity + else: + matrix = similarity + if len(siglist) < 30: - for i, E in enumerate(siglist): + for i, ss in enumerate(siglist): # for small matrices, pretty-print some output - name_num = '{}-{}'.format(i, str(E)) + name_num = '{}-{}'.format(i, str(ss)) if len(name_num) > 20: name_num = name_num[:17] + '...' - print_results('{:20s}\t{}'.format(name_num, similarity[i, :, ],)) + print_results('{:20s}\t{}'.format(name_num, matrix[i, :, ],)) - print_results('min similarity in matrix: {:.3f}', numpy.min(similarity)) - # shall we output a matrix? + if args.distance_matrix: + print_results('max distance in matrix: {:.3f}', numpy.max(matrix)) + else: + print_results('min similarity in matrix: {:.3f}', numpy.min(matrix)) + + # shall we output a matrix to stdout? if args.output: labeloutname = args.output + '.labels.txt' notify(f'saving labels to: {labeloutname}') @@ -180,7 +214,7 @@ def compare(args): notify(f'saving comparison matrix to: {args.output}') with open(args.output, 'wb') as fp: - numpy.save(fp, similarity) + numpy.save(fp, matrix) # output CSV? if args.csv: @@ -191,11 +225,14 @@ def compare(args): for i in range(len(labeltext)): y = [] for j in range(len(labeltext)): - y.append('{}'.format(similarity[i][j])) + y.append(str(matrix[i][j])) w.writerow(y) if size_may_be_inaccurate: - notify("WARNING: size estimation for at least one of these sketches may be inaccurate. ANI values will be set to 0 for these comparisons.") + if args.distance_matrix: + notify("WARNING: size estimation for at least one of these sketches may be inaccurate. ANI distances will be set to 1 for these comparisons.") + else: + notify("WARNING: size estimation for at least one of these sketches may be inaccurate. ANI values will be set to 1 for these comparisons.") def plot(args): @@ -337,8 +374,8 @@ def import_csv(args): notify(f'loaded signature: {name} {s.md5sum()[:8]}') notify(f'saving {len(siglist)} signatures to JSON') - with FileOutput(args.output, 'wt') as outfp: - sig.save_signatures(siglist, outfp) + with SaveSignaturesToLocation(args.output) as save_sig: + save_sig.add_many(siglist) def sbt_combine(args): @@ -409,11 +446,12 @@ def index(args): moltypes.add(sourmash_args.get_moltype(ss)) nums.add(ss.minhash.num) - if args.scaled: - ss.minhash = ss.minhash.downsample(scaled=args.scaled) + with ss.update() as ss: + if args.scaled: + ss.minhash = ss.minhash.downsample(scaled=args.scaled) + if ss.minhash.track_abundance: + ss.minhash = ss.minhash.flatten() - if ss.minhash.track_abundance: - ss.minhash = ss.minhash.flatten() scaleds.add(ss.minhash.scaled) tree.insert(ss) @@ -477,7 +515,8 @@ def search(args): sys.exit(-1) if args.scaled != query.minhash.scaled: notify(f'downsampling query from scaled={query.minhash.scaled} to {int(args.scaled)}') - query.minhash = query.minhash.downsample(scaled=args.scaled) + with query.update() as query: + query.minhash = query.minhash.downsample(scaled=args.scaled) # set up the search databases is_containment = args.containment or args.max_containment @@ -489,17 +528,16 @@ def search(args): databases = sourmash_args.load_dbs_and_sigs(args.databases, query, not is_containment, picklist=picklist, - pattern=pattern_search) - - if not len(databases): - error('Nothing found to search!') - sys.exit(-1) + pattern=pattern_search, + fail_on_empty_database=args.fail_on_empty_database) # handle signatures with abundance if query.minhash.track_abundance: if args.ignore_abundance: - # abund sketch + ignore abundance => flatten sketch. - query.minhash = query.minhash.flatten() + if query.minhash.track_abundance: + # abund sketch + ignore abundance => flatten sketch. + with query.update() as query: + query.minhash = query.minhash.flatten() elif args.containment or args.max_containment: # abund sketch + keep abundance => no containment searches notify("ERROR: cannot do containment searches on an abund signature; maybe specify --ignore-abundance?") @@ -534,10 +572,10 @@ def search(args): args.num_results = 1 if not args.num_results or n_matches <= args.num_results: - print_results('{} matches:'.format(len(results))) + print_results(f'{len(results)} matches above threshold {args.threshold:0.3f}:') else: - print_results('{} matches; showing first {}:', - len(results), args.num_results) + print_results(f'{len(results)} matches above threshold {args.threshold:0.3f}; showing first {args.num_results}:') + n_matches = args.num_results size_may_be_inaccurate = False @@ -584,6 +622,7 @@ def search(args): if jaccard_ani_untrustworthy: notify("WARNING: Jaccard estimation for at least one of these comparisons is likely inaccurate. Could not estimate ANI for these comparisons.") + def categorize(args): "Use a database to find the best match to many signatures." from .index import MultiIndex @@ -627,16 +666,17 @@ def _yield_all_sigs(queries, ksize, moltype): notify(f'loaded query: {str(orig_query)[:30]}... (k={orig_query.minhash.ksize}, {orig_query.minhash.moltype})') - if args.ignore_abundance: + if args.ignore_abundance and orig_query.minhash.track_abundance: query = orig_query.copy() - query.minhash = query.minhash.flatten() + with query.update() as query: + query.minhash = query.minhash.flatten() else: if orig_query.minhash.track_abundance: notify("ERROR: this search cannot be done on signatures calculated with abundance.") notify("ERROR: please specify --ignore-abundance.") sys.exit(-1) - query = orig_query + query = orig_query.copy() results = [] for sr in db.find(search_obj, query): @@ -681,7 +721,8 @@ def gather(args): if args.scaled and args.scaled != query.minhash.scaled: notify(f'downsampling query from scaled={query.minhash.scaled} to {int(args.scaled)}') - query.minhash = query.minhash.downsample(scaled=args.scaled) + with query.update() as query: + query.minhash = query.minhash.downsample(scaled=args.scaled) # empty? if not len(query.minhash): @@ -695,11 +736,9 @@ def gather(args): databases = sourmash_args.load_dbs_and_sigs(args.databases, query, False, cache_size=cache_size, picklist=picklist, - pattern=pattern_search) + pattern=pattern_search, + fail_on_empty_database=args.fail_on_empty_database) - if not len(databases): - error('Nothing found to search!') - sys.exit(-1) if args.linear: # force linear traversal? databases = [ LazyLinearIndex(db) for db in databases ] @@ -708,7 +747,10 @@ def gather(args): if args.prefetch: # note: on by default! notify("Starting prefetch sweep across databases.") prefetch_query = query.copy() - prefetch_query.minhash = prefetch_query.minhash.flatten() + if prefetch_query.minhash.track_abundance: + with prefetch_query.update() as prefetch_query: + prefetch_query.minhash = prefetch_query.minhash.flatten() + noident_mh = prefetch_query.minhash.to_mutable() save_prefetch = SaveSignaturesToLocation(args.save_prefetch) save_prefetch.open() @@ -716,31 +758,32 @@ def gather(args): prefetch_csvout_fp = None prefetch_csvout_w = None if args.save_prefetch_csv: - prefetch_csvout_fp = FileOutput(args.save_prefetch_csv, 'wt').open() + prefetch_csvout_fp = FileOutputCSV(args.save_prefetch_csv).open() query_mh = prefetch_query.minhash scaled = query_mh.scaled counters = [] + ident_mh = noident_mh.copy_and_clear() for db in databases: counter = None try: counter = db.counter_gather(prefetch_query, args.threshold_bp) except ValueError: - if picklist or pattern_search: - # catch "no signatures to search" ValueError from filtering - continue - else: - raise # re-raise other errors, if no picklist. + # catch "no signatures to search" ValueError if empty db. + continue + + save_prefetch.add_many(counter.signatures()) - save_prefetch.add_many(counter.siglist) - # subtract found hashes as we can. - for found_sig in counter.siglist: - noident_mh.remove_many(found_sig.minhash) + # update found/not found hashes from the union/intersection of + # found. + union_found = counter.union_found + ident_mh.add_many(union_found) + noident_mh.remove_many(union_found) - # optionally calculate and save prefetch csv - if prefetch_csvout_fp: - assert scaled + # optionally calculate and output prefetch info to csv + if prefetch_csvout_fp: + for found_sig in counter.signatures(): # calculate intersection stats and info prefetch_result = PrefetchResult(prefetch_query, found_sig, cmp_scaled=scaled, threshold_bp=args.threshold_bp, estimate_ani_ci=args.estimate_ani_ci) @@ -754,7 +797,8 @@ def gather(args): if prefetch_csvout_fp: prefetch_csvout_fp.flush() - notify(f"Found {len(save_prefetch)} signatures via prefetch; now doing gather.") + display_bp = format_bp(args.threshold_bp) + notify(f"Prefetch found {len(save_prefetch)} signatures with overlap >= {display_bp}.") save_prefetch.close() if prefetch_csvout_fp: prefetch_csvout_fp.close() @@ -762,8 +806,10 @@ def gather(args): counters = databases # we can't track unidentified hashes w/o prefetch noident_mh = None + ident_mh = None ## ok! now do gather - + notify("Doing gather to generate minimum metagenome cover.") found = [] weighted_missed = 1 @@ -775,9 +821,15 @@ def gather(args): threshold_bp=args.threshold_bp, ignore_abundance=args.ignore_abundance, noident_mh=noident_mh, + ident_mh=ident_mh, estimate_ani_ci=args.estimate_ani_ci) - for result, weighted_missed in gather_iter: + screen_width = _get_screen_width() + sum_f_uniq_found = 0. + result = None + for result in gather_iter: + sum_f_uniq_found += result.f_unique_to_query + if not len(found): # first result? print header. if is_abundance: print_results("") @@ -792,14 +844,15 @@ def gather(args): # print interim result & save in `found` list for later use pct_query = '{:.1f}%'.format(result.f_unique_weighted*100) pct_genome = '{:.1f}%'.format(result.f_match*100) - name = result.match._display_name(40) if is_abundance: + name = result.match._display_name(screen_width - 41) average_abund ='{:.1f}'.format(result.average_abund) print_results('{:9} {:>7} {:>7} {:>9} {}', format_bp(result.intersect_bp), pct_query, pct_genome, average_abund, name) else: + name = result.match._display_name(screen_width - 31) print_results('{:9} {:>7} {:>7} {}', format_bp(result.intersect_bp), pct_query, pct_genome, name) @@ -808,30 +861,36 @@ def gather(args): if args.num_results and len(found) >= args.num_results: break - # report on thresholding - if gather_iter.query: # if still a query, then we failed the threshold. notify(f'found less than {format_bp(args.threshold_bp)} in common. => exiting') # basic reporting: - print_results(f'\nfound {len(found)} matches total;') - if args.num_results and len(found) == args.num_results: - print_results(f'(truncated gather because --num-results={args.num_results})') - - p_covered = (1 - weighted_missed) * 100 - if is_abundance: - print_results(f'the recovered matches hit {p_covered:.1f}% of the abundance-weighted query') + if found: + print_results(f'\nfound {len(found)} matches total;') + if len(found) == args.num_results: + print_results(f'(truncated gather because --num-results={args.num_results})') else: - print_results(f'the recovered matches hit {p_covered:.1f}% of the query (unweighted)') + display_bp = format_bp(args.threshold_bp) + notify(f"\nNo matches found for --threshold-bp at {display_bp}.") + + if found: + if is_abundance and result: + p_covered = result.sum_weighted_found / result.total_weighted_hashes + p_covered *= 100 + print_results(f'the recovered matches hit {p_covered:.1f}% of the abundance-weighted query.') + + print_results(f'the recovered matches hit {sum_f_uniq_found*100:.1f}% of the query k-mers (unweighted).') + print_results('') if gather_iter.scaled != query.minhash.scaled: print_results(f'WARNING: final scaled was {gather_iter.scaled}, vs query scaled of {query.minhash.scaled}') # save CSV? - w = None - if found and args.output: + if (found and args.output) or args.create_empty_results: with FileOutputCSV(args.output) as fp: + w = None for result in found: if w is None: w = result.init_dictwriter(fp) @@ -861,8 +920,8 @@ def gather(args): abund_query_mh = remaining_query.minhash.inflate(orig_query_mh) remaining_query.minhash = abund_query_mh - with FileOutput(args.output_unassigned, 'wt') as fp: - sig.save_signatures([ remaining_query ], fp) + with SaveSignaturesToLocation(args.output_unassigned) as save_sig: + save_sig.add(remaining_query) if picklist: sourmash_args.report_picklist(args, picklist) @@ -897,11 +956,10 @@ def multigather(args): # need a query to get ksize, moltype for db loading query = next(iter(sourmash_args.load_file_as_signatures(inp_files[0], ksize=args.ksize, select_moltype=moltype))) - databases = sourmash_args.load_dbs_and_sigs(args.db, query, False) + notify(f'loaded first query: {str(query)[:30]}... (k={query.minhash.ksize}, {sourmash_args.get_moltype(query)})') - if not len(databases): - error('Nothing found to search!') - sys.exit(-1) + databases = sourmash_args.load_dbs_and_sigs(args.db, query, False, + fail_on_empty_database=args.fail_on_empty_database) # run gather on all the queries. n=0 @@ -920,7 +978,8 @@ def multigather(args): if args.scaled and args.scaled != query.minhash.scaled: notify(f'downsampling query from scaled={query.minhash.scaled} to {int(args.scaled)}') - query.minhash = query.minhash.downsample(scaled=args.scaled) + with query.update() as query: + query.minhash = query.minhash.downsample(scaled=args.scaled) # empty? if not len(query.minhash): @@ -929,16 +988,27 @@ def multigather(args): counters = [] prefetch_query = query.copy() - prefetch_query.minhash = prefetch_query.minhash.flatten() + if prefetch_query.minhash.track_abundance: + with prefetch_query.update() as prefetch_query: + prefetch_query.minhash = prefetch_query.minhash.flatten() + + ident_mh = prefetch_query.minhash.copy_and_clear() noident_mh = prefetch_query.minhash.to_mutable() counters = [] for db in databases: - counter = db.counter_gather(prefetch_query, args.threshold_bp) - for found_sig in counter.siglist: - noident_mh.remove_many(found_sig.minhash) + try: + counter = db.counter_gather(prefetch_query, args.threshold_bp) + except ValueError: + # catch "no signatures to search" ValueError if empty db. + continue counters.append(counter) + # track found/not found hashes + union_found = counter.union_found + noident_mh.remove_many(union_found) + ident_mh.add_many(union_found) + found = [] weighted_missed = 1 is_abundance = query.minhash.track_abundance and not args.ignore_abundance @@ -946,8 +1016,14 @@ def multigather(args): gather_iter = GatherDatabases(query, counters, threshold_bp=args.threshold_bp, ignore_abundance=args.ignore_abundance, - noident_mh=noident_mh) - for result, weighted_missed in gather_iter: + noident_mh=noident_mh, + ident_mh=ident_mh) + + screen_width = _get_screen_width() + sum_f_uniq_found = 0. + result = None + for result in gather_iter: + sum_f_uniq_found += result.f_unique_to_query if not len(found): # first result? print header. if is_abundance: print_results("") @@ -962,14 +1038,15 @@ def multigather(args): # print interim result & save in a list for later use pct_query = '{:.1f}%'.format(result.f_unique_weighted*100) pct_genome = '{:.1f}%'.format(result.f_match*100) - name = result.match._display_name(40) if is_abundance: + name = result.match._display_name(screen_width - 41) average_abund ='{:.1f}'.format(result.average_abund) print_results('{:9} {:>7} {:>7} {:>9} {}', format_bp(result.intersect_bp), pct_query, pct_genome, average_abund, name) else: + name = result.match._display_name(screen_width - 31) print_results('{:9} {:>7} {:>7} {}', format_bp(result.intersect_bp), pct_query, pct_genome, name) @@ -987,8 +1064,12 @@ def multigather(args): # basic reporting print_results('\nfound {} matches total;', len(found)) - print_results('the recovered matches hit {:.1f}% of the query', - (1 - weighted_missed) * 100) + if is_abundance and result: + p_covered = result.sum_weighted_found / result.total_weighted_hashes + p_covered *= 100 + print_results(f'the recovered matches hit {p_covered:.1f}% of the abundance-weighted query.') + + print_results(f'the recovered matches hit {sum_f_uniq_found*100:.1f}% of the query k-mers (unweighted).') print_results('') if not found: @@ -1002,20 +1083,25 @@ def multigather(args): else: # Uniquify the output file if all signatures were made from the same file (e.g. with --singleton) output_base = os.path.basename(query_filename) + "." + query.md5sum() + query_filename = query.md5sum() + + if args.output_dir: + output_base = os.path.join(args.output_dir, output_base) + output_csv = output_base + '.csv' + notify(f'saving all CSV matches to "{output_csv}"') w = None with FileOutputCSV(output_csv) as fp: for result in found: if w is None: w = result.init_dictwriter(fp) - result.write(w) + result.write(w) output_matches = output_base + '.matches.sig' - with open(output_matches, 'wt') as fp: - outname = output_matches - notify(f'saving all matches to "{outname}"') - sig.save_signatures([ r.match for r in found ], fp) + with SaveSignaturesToLocation(output_matches) as save_sig: + notify(f"saving all matching signatures to '{output_matches}'") + save_sig.add_many([ r.match for r in found ]) output_unassigned = output_base + '.unassigned.sig' with open(output_unassigned, 'wt') as fp: @@ -1036,8 +1122,9 @@ def multigather(args): else: notify(f'saving unassigned hashes to "{output_unassigned}"') + with SaveSignaturesToLocation(output_unassigned) as save_sig: # CTB: note, multigather does not save abundances - sig.save_signatures([ remaining_query ], fp) + save_sig.add(remaining_query) n += 1 # fini, next query! @@ -1134,11 +1221,10 @@ def do_search(): similarity) if args.output: - notify(f'saving signature to {args.output}') - with FileOutput(args.output, 'wt') as fp: - streamsig = sig.SourmashSignature(E, filename='stdin', - name=args.name) - sig.save_signatures([streamsig], fp) + notify(f"saving signature to '{args.output}'") + streamsig = sig.SourmashSignature(E, filename='stdin', name=args.name) + with SaveSignaturesToLocation(args.output) as save_sig: + save_sig.add(streamsig) def migrate(args): @@ -1193,7 +1279,7 @@ def prefetch(args): notify(f'downsampling query from scaled={query_mh.scaled} to {int(args.scaled)}') query_mh = query_mh.downsample(scaled=args.scaled) - notify(f"all sketches will be downsampled to scaled={query_mh.scaled}") + notify(f"query sketch has scaled={query_mh.scaled}; will be dynamically downsampled as needed.") common_scaled = query_mh.scaled # empty? @@ -1201,14 +1287,15 @@ def prefetch(args): error('no query hashes!? exiting.') sys.exit(-1) - query.minhash = query_mh + with query.update() as query: + query.minhash = query_mh ksize = query_mh.ksize # set up CSV output, write headers, etc. csvout_fp = None csvout_w = None if args.output: - csvout_fp = FileOutput(args.output, 'wt').open() + csvout_fp = FileOutputCSV(args.output).open() # track & maybe save matches progressively matches_out = SaveSignaturesToLocation(args.save_matches) @@ -1223,10 +1310,13 @@ def prefetch(args): did_a_search = False # track whether we did _any_ search at all! size_may_be_inaccurate = False + total_signatures_loaded = 0 + sum_signatures_after_select = 0 for dbfilename in args.databases: - notify(f"loading signatures from '{dbfilename}'") + notify(f"loading signatures from '{dbfilename}'", end='\r') db = sourmash_args.load_file_as_index(dbfilename) + total_signatures_loaded += len(db) # force linear traversal? if args.linear: @@ -1235,6 +1325,8 @@ def prefetch(args): db = db.select(ksize=ksize, moltype=moltype, containment=True, scaled=True) + sum_signatures_after_select += len(db) + db = sourmash_args.apply_picklist_and_pattern(db, picklist, pattern_search) @@ -1287,10 +1379,15 @@ def prefetch(args): # delete db explicitly ('cause why not) del db + notify("--") + notify(f"loaded {total_signatures_loaded} total signatures from {len(args.databases)} locations.") + notify(f"after selecting signatures compatible with search, {sum_signatures_after_select} remain.") + if not did_a_search: - notify("ERROR in prefetch: no compatible signatures in any databases?!") + notify("ERROR in prefetch: after picklists and patterns, no signatures to search!?") sys.exit(-1) + notify("--") notify(f"total of {matches_out.count} matching signatures.") matches_out.close() @@ -1316,8 +1413,8 @@ def prefetch(args): ident_mh = ident_mh.inflate(orig_query_mh) ss = sig.SourmashSignature(ident_mh, name=sig_name) - with open(filename, "wt") as fp: - sig.save_signatures([ss], fp) + with SaveSignaturesToLocation(filename) as save_sig: + save_sig.add(ss) if args.save_unmatched_hashes: filename = args.save_unmatched_hashes @@ -1333,8 +1430,8 @@ def prefetch(args): noident_mh = noident_mh.inflate(orig_query_mh) ss = sig.SourmashSignature(noident_mh, name=sig_name) - with open(filename, "wt") as fp: - sig.save_signatures([ss], fp) + with SaveSignaturesToLocation(filename) as save_sig: + save_sig.add(ss) if picklist: sourmash_args.report_picklist(args, picklist) diff --git a/src/sourmash/distance_utils.py b/src/sourmash/distance_utils.py index 1b9b7c56ef..66feb6259c 100644 --- a/src/sourmash/distance_utils.py +++ b/src/sourmash/distance_utils.py @@ -6,6 +6,7 @@ from dataclasses import dataclass, field from scipy.optimize import brentq from scipy.stats import norm as scipy_norm +from scipy.stats import binom import numpy as np from math import log, exp @@ -168,7 +169,8 @@ def set_size_chernoff(set_size, scaled, *, relative_error=0.05): Computes the probability that the estimate: sketch_size * scaled deviates from the true set_size by more than relative_error. This relies on the fact that the sketch_size is binomially distributed with parameters sketch_size and 1/scale. The two-sided Chernoff - bounds are used. + bounds are used. This is depreciated in favor of set_size_exact_prob due to the later + being accurate even for very small set sizes @param set_size: The number of distinct k-mers in the given set @param relative_error: the desired relative error (defaults to 5%) @return: float (the upper bound probability) @@ -177,6 +179,28 @@ def set_size_chernoff(set_size, scaled, *, relative_error=0.05): return upper_bound +def set_size_exact_prob(set_size, scaled, *, relative_error=0.05): + """ + Computes the exact probability that the estimate: sketch_size * scaled deviates from the true + set_size by more than relative_error. This relies on the fact that the sketch_size + is binomially distributed with parameters sketch_size and 1/scale. The CDF of the binomial distribution + is used. + @param set_size: The number of distinct k-mers in the given set + @param relative_error: the desired relative error (defaults to 5%) + @return: float (the upper bound probability) + """ + # Need to check if the edge case is an integer or not. If not, don't include it in the equation + pmf_arg = -set_size/scaled * (relative_error - 1) + if pmf_arg == int(pmf_arg): + prob = binom.cdf(set_size/scaled * (relative_error + 1), set_size, 1/scaled) - \ + binom.cdf(-set_size/scaled * (relative_error - 1), set_size, 1/scaled) + \ + binom.pmf(-set_size/scaled * (relative_error - 1), set_size, 1/scaled) + else: + prob = binom.cdf(set_size / scaled * (relative_error + 1), set_size, 1 / scaled) - \ + binom.cdf(-set_size / scaled * (relative_error - 1), set_size, 1 / scaled) + return prob + + def get_expected_log_probability(n_unique_kmers, ksize, mutation_rate, scaled_fraction): """helper function Note that scaled here needs to be between 0 and 1 diff --git a/src/sourmash/exceptions.py b/src/sourmash/exceptions.py index 4895c50947..b2f18c12d2 100644 --- a/src/sourmash/exceptions.py +++ b/src/sourmash/exceptions.py @@ -25,6 +25,11 @@ def __init__(self): SourmashError.__init__(self, "This index format is not supported in this version of sourmash") +class IndexNotLoaded(SourmashError): + def __init__(self, msg): + SourmashError.__init__(self, f"Cannot load sourmash index: {str(msg)}") + + def _make_error(error_name, base=SourmashError, code=None): class Exc(base): pass diff --git a/src/sourmash/index/__init__.py b/src/sourmash/index/__init__.py index d575132437..08068255e5 100644 --- a/src/sourmash/index/__init__.py +++ b/src/sourmash/index/__init__.py @@ -31,9 +31,6 @@ StandaloneManifestIndex - load manifests directly, and do lazy loading of signatures on demand. No signatures are kept in memory. -LazyLoadedIndex - selection on manifests with loading of index on demand. -(Consider using StandaloneManifestIndex instead.) - CounterGather - an ancillary class returned by the 'counter_gather()' method. """ @@ -41,12 +38,16 @@ import sourmash from abc import abstractmethod, ABC from collections import namedtuple, Counter -from collections import defaultdict -from ..search import make_jaccard_search_query, make_gather_query -from ..manifest import CollectionManifest -from ..logging import debug_literal -from ..signature import load_signatures, save_signatures +from sourmash.search import (make_jaccard_search_query, + make_containment_query, + calc_threshold_from_bp) +from sourmash.manifest import CollectionManifest +from sourmash.logging import debug_literal +from sourmash.signature import load_signatures, save_signatures +from sourmash.minhash import (flatten_and_downsample_scaled, + flatten_and_downsample_num, + flatten_and_intersect_scaled) # generic return tuple for Index.search and Index.gather IndexSearchResult = namedtuple('Result', 'score, signature, location') @@ -111,7 +112,7 @@ def find(self, search_fn, query, **kwargs): search_fn follows the protocol in JaccardSearch objects. - Returns a list. + Generator. Returns 0 or more IndexSearchResult objects. """ # first: is this query compatible with this search? search_fn.check_is_compatible(query) @@ -127,50 +128,19 @@ def find(self, search_fn, query, **kwargs): query_scaled = query_mh.scaled def prepare_subject(subj_mh): - assert subj_mh.scaled - if subj_mh.track_abundance: - subj_mh = subj_mh.flatten() - - # downsample subject to highest scaled - subj_scaled = subj_mh.scaled - if subj_scaled < query_scaled: - return subj_mh.downsample(scaled=query_scaled) - else: - return subj_mh + return flatten_and_downsample_scaled(subj_mh, query_scaled) def prepare_query(query_mh, subj_mh): - assert subj_mh.scaled - - # downsample query to highest scaled - subj_scaled = subj_mh.scaled - if subj_scaled > query_scaled: - return query_mh.downsample(scaled=subj_scaled) - else: - return query_mh + return flatten_and_downsample_scaled(query_mh, subj_mh.scaled) else: # num query_num = query_mh.num def prepare_subject(subj_mh): - assert subj_mh.num - if subj_mh.track_abundance: - subj_mh = subj_mh.flatten() - - # downsample subject to smallest num - subj_num = subj_mh.num - if subj_num > query_num: - return subj_mh.downsample(num=query_num) - else: - return subj_mh + return flatten_and_downsample_num(subj_mh, query_num) def prepare_query(query_mh, subj_mh): - assert subj_mh.num - # downsample query to smallest num - subj_num = subj_mh.num - if subj_num < query_num: - return query_mh.downsample(num=subj_num) - else: - return query_mh + return flatten_and_downsample_num(query_mh, subj_mh.num) # now, do the search! for subj, location in self.signatures_with_location(): @@ -198,7 +168,7 @@ def prepare_query(query_mh, subj_mh): yield IndexSearchResult(score, subj, location) def search_abund(self, query, *, threshold=None, **kwargs): - """Return set of matches with angular similarity above 'threshold'. + """Return list of IndexSearchResult with angular similarity above 'threshold'. Results will be sorted by similarity, highest to lowest. """ @@ -226,7 +196,7 @@ def search_abund(self, query, *, threshold=None, **kwargs): def search(self, query, *, threshold=None, do_containment=False, do_max_containment=False, best_only=False, **kwargs): - """Return set of matches with similarity above 'threshold'. + """Return list of IndexSearchResult with similarity above 'threshold'. Results will be sorted by similarity, highest to lowest. @@ -242,46 +212,56 @@ def search(self, query, *, threshold=None, threshold = float(threshold) search_obj = make_jaccard_search_query(do_containment=do_containment, - do_max_containment=do_max_containment, + do_max_containment=do_max_containment, best_only=best_only, threshold=threshold) # do the actual search: - matches = [] - - for sr in self.find(search_obj, query, **kwargs): - matches.append(sr) + matches = list(self.find(search_obj, query, **kwargs)) # sort! matches.sort(key=lambda x: -x.score) return matches def prefetch(self, query, threshold_bp, **kwargs): - "Return all matches with minimum overlap." + """Return all matches with minimum overlap. + + Generator. Returns 0 or more IndexSearchResult namedtuples. + """ if not self: # empty database? quit. raise ValueError("no signatures to search") - search_fn = make_gather_query(query.minhash, threshold_bp, - best_only=False) + # default best_only to False + best_only = kwargs.get('best_only', False) + + search_fn = make_containment_query(query.minhash, threshold_bp, + best_only=best_only) for sr in self.find(search_fn, query, **kwargs): yield sr - def gather(self, query, threshold_bp=None, **kwargs): - "Return the match with the best Jaccard containment in the Index." + def best_containment(self, query, threshold_bp=None, **kwargs): + """Return the match with the best Jaccard containment in the Index. - results = [] - for result in self.prefetch(query, threshold_bp, **kwargs): - results.append(result) + Returns an IndexSearchResult namedtuple or None. + """ + + results = self.prefetch(query, threshold_bp, best_only=True, **kwargs) + results = sorted(results, + key=lambda x: (-x.score, x.signature.md5sum())) - # sort results by best score. - results.sort(reverse=True, - key=lambda x: (x.score, x.signature.md5sum())) + try: + return next(iter(results)) + except StopIteration: + return None - return results[:1] + def peek(self, query_mh, *, threshold_bp=0): + """Mimic CounterGather.peek() on top of Index. - def peek(self, query_mh, threshold_bp=0): - "Mimic CounterGather.peek() on top of Index. Yes, this is backwards." + This is implemented for situations where we don't want to use + 'prefetch' functionality. It is a light wrapper around the + 'best_containment(...)' method. + """ from sourmash import SourmashSignature # build a signature to use with self.gather... @@ -289,7 +269,7 @@ def peek(self, query_mh, threshold_bp=0): # run query! try: - result = self.gather(query_ss, threshold_bp=threshold_bp) + result = self.best_containment(query_ss, threshold_bp=threshold_bp) except ValueError: result = None @@ -297,14 +277,10 @@ def peek(self, query_mh, threshold_bp=0): return [] # if matches, calculate intersection & return. - sr = result[0] - match_mh = sr.signature.minhash - scaled = max(query_mh.scaled, match_mh.scaled) - match_mh = match_mh.downsample(scaled=scaled).flatten() - query_mh = query_mh.downsample(scaled=scaled) - intersect_mh = match_mh & query_mh + intersect_mh = flatten_and_intersect_scaled(result.signature.minhash, + query_mh) - return [sr, intersect_mh] + return [result, intersect_mh] def consume(self, intersect_mh): "Mimic CounterGather.consume on top of Index. Yes, this is backwards." @@ -319,14 +295,13 @@ def counter_gather(self, query, threshold_bp, **kwargs): implementations need only return an object that meets the public `CounterGather` interface, of course. """ - # build a flat query - prefetch_query = query.copy() - prefetch_query.minhash = prefetch_query.minhash.flatten() + with query.update() as prefetch_query: + prefetch_query.minhash = prefetch_query.minhash.flatten() # find all matches and construct a CounterGather object. - counter = CounterGather(prefetch_query.minhash) + counter = CounterGather(prefetch_query) for result in self.prefetch(prefetch_query, threshold_bp, **kwargs): - counter.add(result.signature, result.location) + counter.add(result.signature, location=result.location) # tada! return counter @@ -462,7 +437,7 @@ class LazyLinearIndex(Index): signatures are actually requested (hence, 'lazy'). * this class stores the provided index 'db' in memory. If you need a class that does lazy loading of signatures from disk and does not - store signatures in memory, see LazyLoadedIndex. + store signatures in memory, see StandaloneManifestIndex. * if you want efficient manifest-based selection, consider MultiIndex (signatures in memory). """ @@ -704,13 +679,29 @@ def select(self, **kwargs): class CounterGather: - """ - Track and summarize matches for efficient 'gather' protocol. This - could be used downstream of prefetch (for example). + """This is an ancillary class that is used to implement "fast + gather", post-prefetch. It tracks and summarize matches for + efficient min-set-cov/'gather'. + + The class constructor takes a query MinHash that must be scaled, and + then takes signatures that have overlaps with the query (via 'add'). + + After all overlapping signatures have been loaded, the 'peek' + method is then used at each stage of the 'gather' procedure to + find the best match, and the 'consume' method is used to remove + a match from this counter. + + This particular implementation maintains a collections.Counter that + is used to quickly find the best match when 'peek' is called, but + other implementations are possible ;). - The public interface is `peek(...)` and `consume(...)` only. + Note that redundant matches (SourmashSignature objects) with + duplicate md5s are collapsed inside the class, because we use the + md5sum as a key into the dictionary used to store matches. """ - def __init__(self, query_mh): + def __init__(self, query): + "Constructor - takes a query SourmashSignature." + query_mh = query.minhash if not query_mh.scaled: raise ValueError('gather requires scaled signatures') @@ -718,17 +709,17 @@ def __init__(self, query_mh): self.orig_query_mh = query_mh.copy().flatten() self.scaled = query_mh.scaled - # track matching signatures & their locations - self.siglist = [] - self.locations = [] + # use these to track loaded matches & their locations + self.siglist = {} + self.locations = {} - # ...and overlaps with query + # ...and also track overlaps with the progressive query self.counter = Counter() - # cannot add matches once query has started. + # fence to make sure we do add matches once query has started. self.query_started = 0 - def add(self, ss, location=None, require_overlap=True): + def add(self, ss, *, location=None, require_overlap=True): "Add this signature in as a potential match." if self.query_started: raise ValueError("cannot add more signatures to counter after peek/consume") @@ -736,11 +727,11 @@ def add(self, ss, location=None, require_overlap=True): # upon insertion, count & track overlap with the specific query. overlap = self.orig_query_mh.count_common(ss.minhash, True) if overlap: - i = len(self.siglist) + md5 = ss.md5sum() - self.counter[i] = overlap - self.siglist.append(ss) - self.locations.append(location) + self.counter[md5] = overlap + self.siglist[md5] = ss + self.locations[md5] = location # note: scaled will be max of all matches. self.downsample(ss.minhash.scaled) @@ -751,26 +742,36 @@ def downsample(self, scaled): "Track highest scaled across all possible matches." if scaled > self.scaled: self.scaled = scaled + return self.scaled + + def signatures(self): + "Return all signatures." + for ss in self.siglist.values(): + yield ss + + @property + def union_found(self): + """Return a MinHash containing all found hashes in the query. - def calc_threshold(self, threshold_bp, scaled, query_size): - # CTB: this code doesn't need to be in this class. - threshold = 0.0 - n_threshold_hashes = 0 + This calculates the union of the found matches, intersected + with the original query. + """ + orig_query_mh = self.orig_query_mh - if threshold_bp: - # if we have a threshold_bp of N, then that amounts to N/scaled - # hashes: - n_threshold_hashes = float(threshold_bp) / scaled + # create empty MinHash from orig query + found_mh = orig_query_mh.copy_and_clear() - # that then requires the following containment: - threshold = n_threshold_hashes / query_size + # for each match, intersect match with query & then add to found_mh. + for ss in self.siglist.values(): + intersect_mh = flatten_and_intersect_scaled(ss.minhash, + orig_query_mh) + found_mh.add_many(intersect_mh) - return threshold, n_threshold_hashes + return found_mh - def peek(self, cur_query_mh, threshold_bp=0): + def peek(self, cur_query_mh, *, threshold_bp=0): "Get next 'gather' result for this database, w/o changing counters." self.query_started = 1 - scaled = cur_query_mh.scaled # empty? nothing to search. counter = self.counter @@ -780,25 +781,25 @@ def peek(self, cur_query_mh, threshold_bp=0): siglist = self.siglist assert siglist - self.downsample(scaled) - scaled = self.scaled + scaled = self.downsample(cur_query_mh.scaled) cur_query_mh = cur_query_mh.downsample(scaled=scaled) if not cur_query_mh: # empty query? quit. return [] + # CTB: could probably remove this check unless debug requested. if cur_query_mh.contained_by(self.orig_query_mh, downsample=True) < 1: raise ValueError("current query not a subset of original query") # are we setting a threshold? - threshold, n_threshold_hashes = self.calc_threshold(threshold_bp, - scaled, - len(cur_query_mh)) - # is it too high to ever match? if so, exit. - if threshold > 1.0: + try: + x = calc_threshold_from_bp(threshold_bp, scaled, len(cur_query_mh)) + threshold, n_threshold_hashes = x + except ValueError: + # too high to ever match => exit return [] - # Find the best match - + # Find the best match using the internal Counter. most_common = counter.most_common() dataset_id, match_size = most_common[0] @@ -806,12 +807,13 @@ def peek(self, cur_query_mh, threshold_bp=0): if match_size < n_threshold_hashes: return [] - ## at this point, we must have a legitimate match above threshold! + ## at this point, we have a legitimate match above threshold! # pull match and location. match = siglist[dataset_id] # calculate containment + # CTB: this check is probably redundant with intersect_mh calc, below. cont = cur_query_mh.contained_by(match.minhash, downsample=True) assert cont assert cont >= threshold @@ -825,7 +827,7 @@ def peek(self, cur_query_mh, threshold_bp=0): return (IndexSearchResult(cont, match, location), intersect_mh) def consume(self, intersect_mh): - "Remove the given hashes from this counter." + "Maintain the internal counter by removing the given hashes." self.query_started = 1 if not intersect_mh: @@ -1058,119 +1060,6 @@ def select(self, **kwargs): prepend_location=self.prepend_location) -class LazyLoadedIndex(Index): - """Given an index location and a manifest, do select only on the manifest - until signatures are actually requested, and only then load the index. - - This class is useful when you have an index object that consume - memory when it is loaded (e.g. JSON signature files, or LCA - databases) and you want to avoid keeping them in memory. The - downside of using this class is that it will load the signatures - from disk every time they are needed (e.g. 'find(...)', 'signatures()'). - - Wrapper class; signatures dynamically loaded from disk; uses manifests. - - CTB: This may be redundant with StandaloneManifestIndex. - """ - def __init__(self, filename, manifest): - "Create an Index with given filename and manifest." - if not os.path.exists(filename): - raise ValueError(f"'{filename}' must exist when creating LazyLoadedIndex") - - if manifest is None: - raise ValueError("manifest cannot be None") - - self.filename = filename - self.manifest = manifest - - @property - def location(self): - "the 'location' attribute for this index will be the filename." - return self.filename - - def signatures(self): - "yield all signatures from the manifest." - if not len(self): - # nothing in manifest? done! - return [] - - # ok - something in manifest, let's go get those signatures! - picklist = self.manifest.to_picklist() - idx = sourmash.load_file_as_index(self.location) - - # convert remaining manifest into picklist - # CTB: one optimization down the road is, for storage-backed - # Index objects, to just reach in and get the signatures directly, - # without going through 'select'. Still, this is nice for abstraction - # because we don't need to care what the index is - it'll work on - # anything. It just might be a bit slower. - idx = idx.select(picklist=picklist) - - # extract signatures. - for ss in idx.signatures(): - yield ss - - def find(self, *args, **kwargs): - """Run find after loading and selecting; this provides 'search', - "'gather', and 'prefetch' functionality, which are built on 'find'. - """ - if not len(self): - # nothing in manifest? done! - return [] - - # ok - something in manifest, let's go get those signatures! - picklist = self.manifest.to_picklist() - idx = sourmash.load_file_as_index(self.location) - - # convert remaining manifest into picklist - # CTB: one optimization down the road is, for storage-backed - # Index objects, to just reach in and get the signatures directly, - # without going through 'select'. Still, this is nice for abstraction - # because we don't need to care what the index is - it'll work on - # anything. It just might be a bit slower. - idx = idx.select(picklist=picklist) - - for x in idx.find(*args, **kwargs): - yield x - - def __len__(self): - "track index size based on the manifest." - return len(self.manifest) - - def __bool__(self): - return bool(self.manifest) - - @classmethod - def load(cls, location): - """Load index from given location, but retain only the manifest. - - Fail if no manifest. - """ - idx = sourmash.load_file_as_index(location) - manifest = idx.manifest - - if not idx.manifest: - raise ValueError(f"no manifest on index at {location}") - - del idx - # NOTE: index is not retained outside this scope, just location. - - return cls(location, manifest) - - def insert(self, *args): - raise NotImplementedError - - def save(self, *args): - raise NotImplementedError - - def select(self, **kwargs): - "Run 'select' on manifest, return new object with new manifest." - manifest = self.manifest - new_manifest = manifest.select_to_manifest(**kwargs) - - return LazyLoadedIndex(self.filename, new_manifest) - - class StandaloneManifestIndex(Index): """Load a standalone manifest as an Index. @@ -1191,11 +1080,6 @@ class StandaloneManifestIndex(Index): StandaloneManifestIndex does _not_ store signatures in memory. - This class overlaps in concept with LazyLoadedIndex and behaves - identically when a manifest contains only rows from a single - on-disk Index object. However, unlike LazyLoadedIndex, this class - can be used to reference multiple on-disk Index objects. - This class also overlaps in concept with MultiIndex when MultiIndex.load_from_pathlist is used to load other Index objects. However, this class does not store any signatures in diff --git a/src/sourmash/index/sqlite_index.py b/src/sourmash/index/sqlite_index.py index 26b26e25ae..16020b17e2 100644 --- a/src/sourmash/index/sqlite_index.py +++ b/src/sourmash/index/sqlite_index.py @@ -405,7 +405,7 @@ def find(self, search_fn, query, **kwargs): if picklist is None or subj in picklist: yield IndexSearchResult(score, subj, self.location) - def select(self, *, num=0, track_abundance=False, **kwargs): + def _select(self, *, num=0, track_abundance=False, **kwargs): "Run a select! This just modifies the manifest." # check SqliteIndex specific conditions on the 'select' if num: @@ -421,9 +421,14 @@ def select(self, *, num=0, track_abundance=False, **kwargs): # modify manifest manifest = manifest.select_to_manifest(**kwargs) + return manifest + + def select(self, *args, **kwargs): + sqlite_manifest = self._select(*args, **kwargs) + # return a new SqliteIndex with a new manifest, but same old conn. return SqliteIndex(self.dbfile, - sqlite_manifest=manifest, + sqlite_manifest=sqlite_manifest, conn=self.conn) # @@ -921,10 +926,10 @@ class LCA_SqliteDatabase(SqliteIndex): """ is_database = True - def __init__(self, dbfile, *, lineage_db=None): + def __init__(self, dbfile, *, lineage_db=None, sqlite_manifest=None): # CTB note: we need to let SqliteIndex open dbfile here, so can't # just pass in a conn. - super().__init__(dbfile) + super().__init__(dbfile, sqlite_manifest=sqlite_manifest) c = self.conn.cursor() @@ -1033,6 +1038,14 @@ def _build_index(self): def insert(self, *args, **kwargs): raise NotImplementedError + # return correct type on select + def select(self, *args, **kwargs): + sqlite_manifest = self._select(*args, **kwargs) + + return LCA_SqliteDatabase(self.dbfile, + sqlite_manifest=sqlite_manifest, + lineage_db=self.lineage_db) + ### LCA_Database API/protocol. def downsample_scaled(self, scaled): diff --git a/src/sourmash/lca/__init__.py b/src/sourmash/lca/__init__.py index c2f3324b0a..b2a9af2589 100644 --- a/src/sourmash/lca/__init__.py +++ b/src/sourmash/lca/__init__.py @@ -2,7 +2,7 @@ from .lca_db import LCA_Database from .lca_utils import (taxlist, zip_lineage, build_tree, find_lca, - gather_assignments, LineagePair, display_lineage, + gather_assignments, display_lineage, count_lca_for_assignments) from .command_index import index diff --git a/src/sourmash/lca/command_classify.py b/src/sourmash/lca/command_classify.py index 0868e0c135..cf5605be72 100644 --- a/src/sourmash/lca/command_classify.py +++ b/src/sourmash/lca/command_classify.py @@ -133,7 +133,10 @@ def classify(args): total_count += 1 # make sure we're looking at the same scaled value as database - query_sig.minhash = query_sig.minhash.downsample(scaled=scaled) + if query_sig.minhash.scaled != scaled: + with query_sig.update() as query_sig: + downsample_mh = query_sig.minhash.downsample(scaled=scaled) + query_sig.minhash = downsample_mh # do the classification lineage, status = classify_signature(query_sig, dblist, diff --git a/src/sourmash/lca/command_index.py b/src/sourmash/lca/command_index.py index ec4cf36362..3ee13164a8 100644 --- a/src/sourmash/lca/command_index.py +++ b/src/sourmash/lca/command_index.py @@ -11,7 +11,6 @@ from sourmash.sourmash_args import load_file_as_signatures from sourmash.logging import notify, error, debug, set_quiet from . import lca_utils -from .lca_utils import LineagePair from .lca_db import LCA_Database from sourmash.sourmash_args import DEFAULT_LOAD_K @@ -26,7 +25,10 @@ def load_taxonomy_assignments(filename, *, delimiter=',', start_column=2, The 'assignments' dictionary that's returned maps identifiers to lineage tuples. """ + from sourmash.tax.tax_utils import LineagePair # parse spreadsheet! + # CTB note: can't easily switch to FileInputCSV, because of + # janky way we do/don't handle headers here. See issue #2198. fp = open(filename, newline='') r = csv.reader(fp, delimiter=delimiter) row_headers = ['identifiers'] diff --git a/src/sourmash/lca/command_summarize.py b/src/sourmash/lca/command_summarize.py index d8acaea867..c571d7e141 100644 --- a/src/sourmash/lca/command_summarize.py +++ b/src/sourmash/lca/command_summarize.py @@ -65,10 +65,10 @@ def load_singletons_and_count(filenames, ksize, scaled, ignore_abundance): total_n = len(filenames) for filename in filenames: n += 1 - mi = MultiIndex.load_from_path(filename) - mi = mi.select(ksize=ksize) + idx = sourmash_args.load_file_as_index(filename) + idx = idx.select(ksize=ksize) - for query_sig, query_filename in mi.signatures_with_location(): + for query_sig, query_filename in idx.signatures_with_location(): notify(u'\r\033[K', end=u'') notify(f'... loading {query_sig} (file {n} of {total_n})', total_n, end='\r') diff --git a/src/sourmash/lca/lca_db.py b/src/sourmash/lca/lca_db.py index 280f810426..daabe3cb70 100644 --- a/src/sourmash/lca/lca_db.py +++ b/src/sourmash/lca/lca_db.py @@ -262,7 +262,8 @@ def load(cls, db_name): Method specific to this class. """ - from .lca_utils import taxlist, LineagePair + from .lca_utils import taxlist + from sourmash.tax.tax_utils import LineagePair if not os.path.isfile(db_name): raise ValueError(f"'{db_name}' is not a file and cannot be loaded as an LCA database") @@ -325,7 +326,7 @@ def load(cls, db_name): lid_to_lineage = {} lineage_to_lid = {} for k, v in lid_to_lineage_2.items(): - v = dict(v) + v = dict( ((x[0], x[1]) for x in v) ) vv = [] for rank in taxlist(): name = v.get(rank, '') @@ -460,7 +461,7 @@ def downsample_scaled(self, scaled): max_hash = _get_max_hash_for_scaled(scaled) # filter out all hashes over max_hash in value. - new_hashvals = {} + new_hashvals = defaultdict(set) for k, v in self._hashval_to_idx.items(): if k < max_hash: new_hashvals[k] = v @@ -553,9 +554,10 @@ def _signatures(self): ident = self._idx_to_ident[idx] name = self._ident_to_name[ident] ss = SourmashSignature(mh, name=name) + ss.into_frozen() if passes_all_picklists(ss, self.picklists): - sigd[idx] = SourmashSignature(mh, name=name) + sigd[idx] = ss debug('=> {} signatures!', len(sigd)) return sigd diff --git a/src/sourmash/lca/lca_utils.py b/src/sourmash/lca/lca_utils.py index b81f765bb9..8ee9340ed7 100644 --- a/src/sourmash/lca/lca_utils.py +++ b/src/sourmash/lca/lca_utils.py @@ -103,17 +103,6 @@ def display_lineage(lineage, include_strain=True, truncate_empty=True): null_names = set(['[Blank]', 'na', 'null']) -def make_lineage(lineage_str): - "Turn a ; or ,-separated set of lineages into a tuple of LineagePair objs." - lin = lineage_str.split(';') - if len(lin) == 1: - lin = lineage.split(',') - lin = [ LineagePair(rank, n) for (rank, n) in zip(taxlist(), lin) ] - lin = tuple(lin) - - return lin - - def build_tree(assignments, initial=None): """ Builds a tree of dictionaries from lists of LineagePair objects @@ -245,6 +234,7 @@ def pop_to_rank(lin, rank): def make_lineage(lineage): "Turn a ; or ,-separated set of lineages into a tuple of LineagePair objs." + from sourmash.tax.tax_utils import LineagePair lin = lineage.split(';') if len(lin) == 1: lin = lineage.split(',') diff --git a/src/sourmash/manifest.py b/src/sourmash/manifest.py index e2431dde36..bfd27eabb9 100644 --- a/src/sourmash/manifest.py +++ b/src/sourmash/manifest.py @@ -3,6 +3,7 @@ """ import csv import ast +import gzip import os.path from abc import abstractmethod import itertools @@ -40,8 +41,13 @@ def load_from_filename(cls, filename): if db is not None: return db - # not a SQLite db? - with open(filename, newline="") as fp: + # not a SQLite db? CTB: fix this to actually try loading this as .gz... + if filename.endswith('.gz'): + xopen = gzip.open + else: + xopen = open + + with xopen(filename, 'rt', newline="") as fp: return cls.load_from_csv(fp) @classmethod @@ -92,8 +98,9 @@ def load_from_sql(cls, filename): def write_to_filename(self, filename, *, database_format='csv', ok_if_exists=False): if database_format == 'csv': + from .sourmash_args import FileOutputCSV if ok_if_exists or not os.path.exists(filename): - with open(filename, "w", newline="") as fp: + with FileOutputCSV(filename) as fp: return self.write_to_csv(fp, write_header=True) elif os.path.exists(filename) and not ok_if_exists: raise Exception("output manifest already exists") diff --git a/src/sourmash/minhash.py b/src/sourmash/minhash.py index 76b34d96c6..360ca6165b 100644 --- a/src/sourmash/minhash.py +++ b/src/sourmash/minhash.py @@ -6,7 +6,7 @@ class MinHash - core MinHash class. class FrozenMinHash - read-only MinHash class. """ from __future__ import unicode_literals, division -from .distance_utils import jaccard_to_distance, containment_to_distance, set_size_chernoff +from .distance_utils import jaccard_to_distance, containment_to_distance, set_size_exact_prob from .logging import notify import numpy as np @@ -103,6 +103,39 @@ def translate_codon(codon): raise ValueError(e.message) +def flatten_and_downsample_scaled(mh, *scaled_vals): + "Flatten MinHash object and downsample to max of scaled values." + assert mh.scaled + assert all( (x > 0 for x in scaled_vals) ) + + mh = mh.flatten() + scaled = max(scaled_vals) + if scaled > mh.scaled: + return mh.downsample(scaled=scaled) + return mh + + +def flatten_and_downsample_num(mh, *num_vals): + "Flatten MinHash object and downsample to min of num values." + assert mh.num + assert all( (x > 0 for x in num_vals) ) + + mh = mh.flatten() + num = min(num_vals) + if num < mh.num: + return mh.downsample(num=num) + return mh + + +def flatten_and_intersect_scaled(mh1, mh2): + "Flatten and downsample two scaled MinHash objs, then return intersection." + scaled = max(mh1.scaled, mh2.scaled) + mh1 = mh1.flatten().downsample(scaled=scaled) + mh2 = mh2.flatten().downsample(scaled=scaled) + + return mh1 & mh2 + + class _HashesWrapper(Mapping): "A read-only view of the hashes contained by a MinHash object." def __init__(self, h): @@ -159,6 +192,7 @@ def __init__( self, n, ksize, + *, is_protein=False, dayhoff=False, hp=False, @@ -241,9 +275,13 @@ def __copy__(self): def __getstate__(self): "support pickling via __getstate__/__setstate__" + + # note: we multiple ksize by 3 here so that + # pickle protocols that bypass __setstate__ + # get a ksize that makes sense to the Rust layer. See #2262. return ( self.num, - self.ksize, + self.ksize if self.is_dna else self.ksize*3, self.is_protein, self.dayhoff, self.hp, @@ -286,12 +324,12 @@ def copy_and_clear(self): a = MinHash( self.num, self.ksize, - self.is_protein, - self.dayhoff, - self.hp, - self.track_abundance, - self.seed, - self._max_hash, + is_protein=self.is_protein, + dayhoff=self.dayhoff, + hp=self.hp, + track_abundance=self.track_abundance, + seed=self.seed, + max_hash=self._max_hash, ) return a @@ -620,8 +658,10 @@ def downsample(self, *, num=None, scaled=None): # end checks! create new object: a = MinHash( - num, self.ksize, self.is_protein, self.dayhoff, self.hp, - self.track_abundance, self.seed, max_hash + num, self.ksize, + is_protein=self.is_protein, dayhoff=self.dayhoff, hp=self.hp, + track_abundance=self.track_abundance, seed=self.seed, + max_hash=max_hash ) # copy over hashes: if self.track_abundance: @@ -636,8 +676,9 @@ def flatten(self): if self.track_abundance: # create new object: a = MinHash( - self.num, self.ksize, self.is_protein, self.dayhoff, self.hp, - False, self.seed, self._max_hash + self.num, self.ksize, + is_protein=self.is_protein, dayhoff=self.dayhoff, hp=self.hp, + track_abundance=False, seed=self.seed, max_hash=self._max_hash ) a.add_many(self) @@ -708,16 +749,24 @@ def contained_by(self, other, downsample=False): Calculate how much of self is contained by other. """ if not (self.scaled and other.scaled): - raise TypeError("can only calculate containment for scaled MinHashes") - if not len(self): + raise TypeError("Error: can only calculate containment for scaled MinHashes") + denom = len(self) + if not denom: + return 0.0 + total_denom = float(denom * self.scaled) # would be better if hll estimate - see #1798 + bias_factor = 1.0 - (1.0 - 1.0/self.scaled) ** total_denom + containment = self.count_common(other, downsample) / (denom * bias_factor) + # debiasing containment can lead to vals outside of 0-1 range. constrain. + if containment >= 1: + return 1.0 + elif containment <= 0: return 0.0 - return self.count_common(other, downsample) / len(self) - # with bias factor - #return self.count_common(other, downsample) / (len(self) * (1- (1-1/self.scaled)^(len(self)*self.scaled))) + else: + return containment def containment_ani(self, other, *, downsample=False, containment=None, confidence=0.95, estimate_ci = False, prob_threshold=1e-3): - "Use containment to estimate ANI between two MinHash objects." + "Use self contained by other to estimate ANI between two MinHash objects." if not (self.scaled and other.scaled): raise TypeError("Error: can only calculate ANI for scaled MinHashes") self_mh = self @@ -739,20 +788,27 @@ def containment_ani(self, other, *, downsample=False, containment=None, confiden c_aniresult.size_is_inaccurate = True return c_aniresult - def max_containment(self, other, downsample=False): """ Calculate maximum containment. """ if not (self.scaled and other.scaled): - raise TypeError("can only calculate containment for scaled MinHashes") + raise TypeError("Error: can only calculate containment for scaled MinHashes") min_denom = min((len(self), len(other))) if not min_denom: return 0.0 + total_denom = float(min_denom * self.scaled) # would be better if hll estimate - see #1798 + bias_factor = 1.0 - (1.0 - 1.0/self.scaled) ** total_denom + max_containment = self.count_common(other, downsample) / (min_denom * bias_factor) + # debiasing containment can lead to vals outside of 0-1 range. constrain. + if max_containment >= 1: + return 1.0 + elif max_containment <= 0: + return 0.0 + else: + return max_containment - return self.count_common(other, downsample) / min_denom - - def max_containment_ani(self, other, *, downsample=False, max_containment=None, confidence=0.95, estimate_ci=False, prob_threshold=1e-3): + def max_containment_ani(self, other, *, downsample=False, max_containment=None, confidence=0.95, estimate_ci=False, prob_threshold=1e-3): "Use max_containment to estimate ANI between two MinHash objects." if not (self.scaled and other.scaled): raise TypeError("Error: can only calculate ANI for scaled MinHashes") @@ -782,7 +838,7 @@ def avg_containment(self, other, *, downsample=False): Note: this is average of the containments, *not* count_common/ avg_denom """ if not (self.scaled and other.scaled): - raise TypeError("can only calculate containment for scaled MinHashes") + raise TypeError("Error: can only calculate containment for scaled MinHashes") c1 = self.contained_by(other, downsample) c2 = other.contained_by(self, downsample) @@ -882,9 +938,13 @@ def to_mutable(self): def to_frozen(self): "Return a frozen copy of this MinHash that cannot be changed." new_mh = self.__copy__() - new_mh.__class__ = FrozenMinHash + new_mh.into_frozen() return new_mh + def into_frozen(self): + "Freeze this MinHash, preventing any changes." + self.__class__ = FrozenMinHash + def inflate(self, from_mh): """return a new MinHash object with abundances taken from 'from_mh' @@ -937,9 +997,9 @@ def unique_dataset_hashes(self): if not self.scaled: raise TypeError("can only approximate unique_dataset_hashes for scaled MinHashes") # TODO: replace set_size with HLL estimate when that gets implemented - return len(self.hashes) * self.scaled # + (self.ksize - 1) for bp estimation + return len(self) * self.scaled # + (self.ksize - 1) for bp estimation - def size_is_accurate(self, relative_error=0.05, confidence=0.95): + def size_is_accurate(self, relative_error=0.20, confidence=0.95): """ Computes the probability that the estimate: sketch_size * scaled deviates from the true set_size by more than relative_error. This relies on the fact that the sketch_size @@ -952,7 +1012,7 @@ def size_is_accurate(self, relative_error=0.05, confidence=0.95): if any([not (0 <= relative_error <= 1), not (0 <= confidence <= 1)]): raise ValueError("Error: relative error and confidence values must be between 0 and 1.") # to do: replace unique_dataset_hashes with HLL estimation when it gets implemented - probability = set_size_chernoff(self.unique_dataset_hashes, self.scaled, relative_error=relative_error) + probability = set_size_exact_prob(self.unique_dataset_hashes, self.scaled, relative_error=relative_error) return probability >= confidence @@ -990,12 +1050,16 @@ def downsample(self, *, num=None, scaled=None): if num and self.num == num: return self - return MinHash.downsample(self, num=num, scaled=scaled).to_frozen() + down_mh = MinHash.downsample(self, num=num, scaled=scaled) + down_mh.into_frozen() + return down_mh def flatten(self): if not self.track_abundance: return self - return MinHash.flatten(self).to_frozen() + flat_mh = MinHash.flatten(self) + flat_mh.into_frozen() + return flat_mh def __iadd__(self, *args, **kwargs): raise TypeError('FrozenMinHash does not support modification') @@ -1008,11 +1072,6 @@ def to_mutable(self): mut = MinHash.__new__(MinHash) state_tup = self.__getstate__() - # is protein/hp/dayhoff? - if state_tup[2] or state_tup[3] or state_tup[4]: - state_tup = list(state_tup) - # adjust ksize. - state_tup[1] = state_tup[1] * 3 mut.__setstate__(state_tup) return mut @@ -1020,6 +1079,10 @@ def to_frozen(self): "Return a frozen copy of this MinHash that cannot be changed." return self + def into_frozen(self): + "Freeze this MinHash, preventing any changes." + pass + def __setstate__(self, tup): "support pickling via __getstate__/__setstate__" (n, ksize, is_protein, dayhoff, hp, mins, _, track_abundance, diff --git a/src/sourmash/picklist.py b/src/sourmash/picklist.py index f48377e4cb..30d5c84f90 100644 --- a/src/sourmash/picklist.py +++ b/src/sourmash/picklist.py @@ -140,8 +140,10 @@ def init(self, values=[]): self.pickset = set(values) return self.pickset - def load(self, pickfile, column_name): + def load(self, pickfile, column_name, *, allow_empty=False): "load pickset, return num empty vals, and set of duplicate vals." + from . import sourmash_args + pickset = self.init() if not os.path.exists(pickfile) or not os.path.isfile(pickfile): @@ -149,18 +151,16 @@ def load(self, pickfile, column_name): n_empty_val = 0 dup_vals = set() - with open(pickfile, newline='') as csvfile: - x = csvfile.readline() - - # skip leading comment line in case there's a manifest header - if not x or x[0] == '#': - pass - else: - csvfile.seek(0) - r = csv.DictReader(csvfile) + # CTB: not clear to me what a good "default" name would be for a + # picklist CSV inside a zip (default_csv_name). Maybe manifest? + with sourmash_args.FileInputCSV(pickfile) as r: + self.pickfile = pickfile if not r.fieldnames: - raise ValueError(f"empty or improperly formatted pickfile '{pickfile}'") + if not allow_empty: + raise ValueError(f"empty or improperly formatted pickfile '{pickfile}'") + else: + return 0, 0 if column_name not in r.fieldnames: raise ValueError(f"column '{column_name}' not in pickfile '{pickfile}'") @@ -180,7 +180,6 @@ def load(self, pickfile, column_name): else: self.add(col) - self.pickfile = pickfile return n_empty_val, dup_vals def add(self, value): diff --git a/src/sourmash/plugins.py b/src/sourmash/plugins.py new file mode 100644 index 0000000000..4c18f27533 --- /dev/null +++ b/src/sourmash/plugins.py @@ -0,0 +1,197 @@ +""" +Support for plugins to sourmash via importlib.metadata entrypoints. + +Plugin entry point names: +* 'sourmash.load_from' - Index class loading. +* 'sourmash.save_to' - Signature saving. +* 'sourmash.cli_script' - command-line extension. + +CTB TODO: + +* consider using something other than 'name' for loader fn name. Maybe __doc__? +* try implement picklist plugin? +""" + +DEFAULT_LOAD_FROM_PRIORITY = 99 +DEFAULT_SAVE_TO_PRIORITY = 99 + +import itertools +import argparse + +from .logging import (debug_literal, error, notify, set_quiet) + +# cover for older versions of Python that don't support selection on load +# (the 'group=' below). +from importlib.metadata import entry_points + +# load 'load_from' entry points. NOTE: this executes on import of this module. +try: + _plugin_load_from = entry_points(group='sourmash.load_from') +except TypeError: + from importlib_metadata import entry_points + _plugin_load_from = entry_points(group='sourmash.load_from') + +# load 'save_to' entry points as well. +_plugin_save_to = entry_points(group='sourmash.save_to') + +# aaaaand CLI entry points: +_plugin_cli = entry_points(group='sourmash.cli_script') +_plugin_cli_once = False + +### + +def get_load_from_functions(): + "Load the 'load_from' plugins and yield tuples (priority, name, fn)." + debug_literal(f"load_from plugins: {_plugin_load_from}") + + # Load each plugin, + for plugin in _plugin_load_from: + try: + loader_fn = plugin.load() + except (ModuleNotFoundError, AttributeError) as e: + debug_literal(f"plugins.load_from_functions: got error loading {plugin.name}: {str(e)}") + continue + + # get 'priority' if it is available + priority = getattr(loader_fn, 'priority', DEFAULT_LOAD_FROM_PRIORITY) + + # retrieve name (which is specified by plugin?) + name = plugin.name + debug_literal(f"plugins.load_from_functions: got '{name}', priority={priority}") + yield priority, name, loader_fn + + +def get_save_to_functions(): + "Load the 'save_to' plugins and yield tuples (priority, fn)." + debug_literal(f"save_to plugins: {_plugin_save_to}") + + # Load each plugin, + for plugin in _plugin_save_to: + try: + save_cls = plugin.load() + except (ModuleNotFoundError, AttributeError) as e: + debug_literal(f"plugins.load_from_functions: got error loading {plugin.name}: {str(e)}") + continue + + # get 'priority' if it is available + priority = getattr(save_cls, 'priority', DEFAULT_SAVE_TO_PRIORITY) + + # retrieve name (which is specified by plugin?) + name = plugin.name + debug_literal(f"plugins.save_to_functions: got '{name}', priority={priority}") + yield priority, save_cls + + +class CommandLinePlugin: + """ + Provide some minimal common CLI functionality - -q and -d. + + Subclasses should call super().__init__(parser) and super().main(args). + """ + command = None + description = None + + def __init__(self, parser): + parser.add_argument( + '-q', '--quiet', action='store_true', + help='suppress non-error output' + ) + parser.add_argument( + '-d', '--debug', action='store_true', + help='provide debugging output' + ) + + def main(self, args): + set_quiet(args.quiet, args.debug) + + +def get_cli_script_plugins(): + global _plugin_cli_once + + x = [] + for plugin in _plugin_cli: + name = plugin.name + mod = plugin.module + try: + script_cls = plugin.load() + except (ModuleNotFoundError, AttributeError): + if _plugin_cli_once is False: + error(f"ERROR: cannot find or load module for cli_script plugin '{name}'") + continue + + command = getattr(script_cls, 'command', None) + if command is None: + # print error message only once... + if _plugin_cli_once is False: + error(f"ERROR: no command provided by cli_script plugin '{name}' from {mod}; skipping") + else: + x.append(plugin) + + _plugin_cli_once = True + return x + + +def get_cli_scripts_descriptions(): + "Build the descriptions for command-line plugins." + for plugin in get_cli_script_plugins(): + name = plugin.name + script_cls = plugin.load() + + command = getattr(script_cls, 'command') + description = getattr(script_cls, 'description', "") + if description: + description = description.splitlines()[0] + if not description: + description = f"(no description provided by plugin '{name}')" + + yield f"sourmash scripts {command:16s} - {description}" + + +def add_cli_scripts(parser): + "Configure parsing for command-line plugins." + d = {} + + for plugin in get_cli_script_plugins(): + name = plugin.name + script_cls = plugin.load() + + usage = getattr(script_cls, 'usage', None) + description = getattr(script_cls, 'description', None) + epilog = getattr(script_cls, 'epilog', None) + formatter_class = getattr(script_cls, 'formatter_class', + argparse.HelpFormatter) + + subparser = parser.add_parser(script_cls.command, + usage=usage, + description=description, + epilog=epilog, + formatter_class=formatter_class) + debug_literal(f"cls_script plugin '{name}' adding command '{script_cls.command}'") + obj = script_cls(subparser) + d[script_cls.command] = obj + + return d + + +def list_all_plugins(): + plugins = itertools.chain(_plugin_load_from, + _plugin_save_to, + _plugin_cli) + plugins = list(plugins) + + if not plugins: + notify("\n(no plugins detected)\n") + + notify("") + notify("the following plugins are installed:") + notify("") + notify(f"{'plugin type':<20s} {'from python module':<30s} {'v':<5s} {'entry point name':<20s}") + notify(f"{'-'*20} {'-'*30} {'-'*5} {'-'*20}") + + for plugin in plugins: + name = plugin.name + mod = plugin.module + version = plugin.dist.version + group = plugin.group + + notify(f"{group:<20s} {mod:<30s} {version:<5s} {name:<20s}") diff --git a/src/sourmash/save_load.py b/src/sourmash/save_load.py new file mode 100644 index 0000000000..bb842bd02e --- /dev/null +++ b/src/sourmash/save_load.py @@ -0,0 +1,530 @@ +""" +Index object/sigfile loading and signature saving code. + +This is the middleware code responsible for loading and saving signatures +in a variety of ways. + +--- + +Command-line functionality goes in sourmash_args.py. + +Low-level JSON reading/writing is in signature.py. + +Index objects are implemented in the index submodule. + +Public API: + +* load_file_as_index(filename, ...) -- load a sourmash.Index class +* SaveSignaturesToLocation(filename) - bulk signature output + +APIs for plugins to use: + +* class Base_SaveSignaturesToLocation - to implement a new output method. + +CTB TODO: +* consider replacing ValueError with IndexNotLoaded in the future. +""" +import sys +import os +import gzip +from io import StringIO +import zipfile +import itertools +import traceback + +import screed +import sourmash + +from . import plugins as sourmash_plugins +from .logging import notify, debug_literal +from .exceptions import IndexNotLoaded + +from .index.sqlite_index import load_sqlite_index, SqliteIndex +from .sbtmh import load_sbt_index +from .lca.lca_db import load_single_database +from . import signature as sigmod +from .index import (LinearIndex, ZipFileLinearIndex, MultiIndex) +from .manifest import CollectionManifest + + +def load_file_as_index(filename, *, yield_all_files=False): + """Load 'filename' as a database; generic database loader. + + If 'filename' contains an SBT or LCA indexed database, or a regular + Zip file, will return the appropriate objects. If a Zip file and + yield_all_files=True, will try to load all files within zip, not just + .sig files. + + If 'filename' is a JSON file containing one or more signatures, will + return an Index object containing those signatures. + + If 'filename' is a directory, will load *.sig underneath + this directory into an Index object. If yield_all_files=True, will + attempt to load all files. + """ + return _load_database(filename, yield_all_files) + + +def SaveSignaturesToLocation(location): + """ + Provides a context manager that saves signatures in various output formats. + + Usage: + + with SaveSignaturesToLocation(filename_or_location) as save_sigs: + save_sigs.add(sig_obj) + """ + save_list = itertools.chain(_save_classes, + sourmash_plugins.get_save_to_functions()) + for priority, cls in sorted(save_list, key=lambda x:x[0]): + debug_literal(f"trying to match save function {cls}, priority={priority}") + + if cls.matches(location): + debug_literal(f"{cls} is a match!") + return cls(location) + + raise Exception(f"cannot determine how to open location {location} for saving; this should never happen!?") + +### Implementation machinery for _load_databases + + +def _load_database(filename, traverse_yield_all, *, cache_size=None): + """Load file as a database - list of signatures, LCA, SBT, etc. + + Return Index object. + + This is an internal function used by other functions in sourmash_args. + """ + loaded = False + + # load plugins + plugin_fns = sourmash_plugins.get_load_from_functions() + + # aggregate with default load_from functions & sort by priority + load_from_functions = sorted(itertools.chain(_loader_functions, + plugin_fns)) + + # iterate through loader functions, sorted by priority; try them all. + # Catch ValueError & IndexNotLoaded but nothing else. + for (priority, desc, load_fn) in load_from_functions: + db = None + try: + debug_literal(f"_load_databases: trying loader fn - priority {priority} - '{desc}'") + db = load_fn(filename, + traverse_yield_all=traverse_yield_all, + cache_size=cache_size) + except (ValueError, IndexNotLoaded): + debug_literal(f"_load_databases: FAIL with ValueError: on fn {desc}.") + debug_literal(traceback.format_exc()) + debug_literal("(continuing past exception)") + + if db is not None: + loaded = True + debug_literal("_load_databases: success!") + break + + if loaded: + assert db is not None + return db + + raise ValueError(f"Error while reading signatures from '{filename}'.") + + +_loader_functions = [] +def add_loader(name, priority): + "decorator to add name/priority to _loader_functions" + def dec_priority(func): + _loader_functions.append((priority, name, func)) + return func + return dec_priority + + +@add_loader("load from stdin", 10) +def _load_stdin(filename, **kwargs): + "Load collection from .sig file streamed in via stdin" + db = None + if filename == '-': + # load as LinearIndex, then pass into MultiIndex to generate a + # manifest. + lidx = LinearIndex.load(sys.stdin, filename='-') + db = MultiIndex.load((lidx,), (None,), parent="-") + + return db + + +@add_loader("load from standalone manifest", 30) +def _load_standalone_manifest(filename, **kwargs): + from sourmash.index import StandaloneManifestIndex + + try: + idx = StandaloneManifestIndex.load(filename) + except gzip.BadGzipFile as exc: + raise IndexNotLoaded(exc) + + return idx + + +@add_loader("load from list of paths", 50) +def _multiindex_load_from_pathlist(filename, **kwargs): + "Load collection from a list of signature/database files" + db = MultiIndex.load_from_pathlist(filename) + + return db + + +@add_loader("load from path (file or directory)", 40) +def _multiindex_load_from_path(filename, **kwargs): + "Load collection from a directory." + traverse_yield_all = kwargs['traverse_yield_all'] + db = MultiIndex.load_from_path(filename, traverse_yield_all) + + return db + + +@add_loader("load SBT", 60) +def _load_sbt(filename, **kwargs): + "Load collection from an SBT." + cache_size = kwargs.get('cache_size') + + try: + db = load_sbt_index(filename, cache_size=cache_size) + except (FileNotFoundError, TypeError) as exc: + raise IndexNotLoaded(exc) + + return db + + +@add_loader("load revindex", 70) +def _load_revindex(filename, **kwargs): + "Load collection from an LCA database/reverse index." + db, _, _ = load_single_database(filename) + return db + + +@add_loader("load collection from sqlitedb", 20) +def _load_sqlite_db(filename, **kwargs): + return load_sqlite_index(filename) + + +@add_loader("load collection from zipfile", 80) +def _load_zipfile(filename, **kwargs): + "Load collection from a .zip file." + db = None + if filename.endswith('.zip'): + traverse_yield_all = kwargs['traverse_yield_all'] + try: + db = ZipFileLinearIndex.load(filename, + traverse_yield_all=traverse_yield_all) + except FileNotFoundError as exc: + # turn this into an IndexNotLoaded => proper exception handling by + # _load_database. + raise IndexNotLoaded(exc) + + return db + + +@add_loader("catch FASTA/FASTQ files and error", 1000) +def _error_on_fastaq(filename, **kwargs): + "This is a tail-end loader that checks for FASTA/FASTQ sequences => err." + success = False + try: + with screed.open(filename) as it: + _ = next(iter(it)) + + success = True + except: + pass + + if success: + raise Exception(f"Error while reading signatures from '{filename}' - got sequences instead! Is this a FASTA/FASTQ file?") + + +### Implementation machinery for SaveSignaturesToLocation + +class Base_SaveSignaturesToLocation: + "Base signature saving class. Track location (if any) and count." + def __init__(self, location): + self.location = location + self.count = 0 + + @classmethod + def matches(cls, location): + "returns True when this class should handle a specific location" + raise NotImplementedError + + def __repr__(self): + raise NotImplementedError + + def __len__(self): + return self.count + + def open(self): + pass + + def close(self): + pass + + def __enter__(self): + "provide context manager functionality" + self.open() + return self + + def __exit__(self, type, value, traceback): + "provide context manager functionality" + self.close() + + def add(self, ss): + self.count += 1 + + def add_many(self, sslist): + for ss in sslist: + self.add(ss) + + +def _get_signatures_from_rust(siglist): + # this function deals with a disconnect between the way Rust + # and Python handle signatures; Python expects one + # minhash (and hence one md5sum) per signature, while + # Rust supports multiple. For now, go through serializing + # and deserializing the signature! See issue #1167 for more. + json_str = sourmash.save_signatures(siglist) + for ss in sourmash.load_signatures(json_str): + yield ss + + +class SaveSignatures_NoOutput(Base_SaveSignaturesToLocation): + "Do not save signatures." + def __repr__(self): + return 'SaveSignatures_NoOutput()' + + @classmethod + def matches(cls, location): + return location is None + + def open(self): + pass + + def close(self): + pass + + +class SaveSignatures_Directory(Base_SaveSignaturesToLocation): + "Save signatures within a directory, using md5sum names." + def __init__(self, location): + super().__init__(location) + + def __repr__(self): + return f"SaveSignatures_Directory('{self.location}')" + + @classmethod + def matches(cls, location): + "anything ending in /" + if location: + return location.endswith('/') + + def close(self): + pass + + def open(self): + try: + os.mkdir(self.location) + except FileExistsError: + pass + except: + notify(f"ERROR: cannot create signature output directory '{self.location}'") + sys.exit(-1) + + def add(self, ss): + super().add(ss) + md5 = ss.md5sum() + + # don't overwrite even if duplicate md5sum + outname = os.path.join(self.location, f"{md5}.sig.gz") + if os.path.exists(outname): + i = 0 + while 1: + outname = os.path.join(self.location, f"{md5}_{i}.sig.gz") + if not os.path.exists(outname): + break + i += 1 + + with gzip.open(outname, "wb") as fp: + sigmod.save_signatures([ss], fp, compression=1) + + +class SaveSignatures_SqliteIndex(Base_SaveSignaturesToLocation): + "Save signatures within a directory, using md5sum names." + def __init__(self, location): + super().__init__(location) + self.location = location + self.idx = None + self.cursor = None + + @classmethod + def matches(cls, location): + "anything ending in .sqldb" + if location: + return location.endswith('.sqldb') + + def __repr__(self): + return f"SaveSignatures_SqliteIndex('{self.location}')" + + def close(self): + self.idx.commit() + self.cursor.execute('VACUUM') + self.idx.close() + + def open(self): + self.idx = SqliteIndex.create(self.location, append=True) + self.cursor = self.idx.cursor() + + def add(self, add_sig): + for ss in _get_signatures_from_rust([add_sig]): + super().add(ss) + self.idx.insert(ss, cursor=self.cursor, commit=False) + + # commit every 1000 signatures. + if self.count % 1000 == 0: + self.idx.commit() + + +class SaveSignatures_SigFile(Base_SaveSignaturesToLocation): + "Save signatures to a .sig JSON file." + def __init__(self, location): + super().__init__(location) + self.keep = [] + self.compress = 0 + if self.location.endswith('.gz'): + self.compress = 1 + + @classmethod + def matches(cls, location): + # match anything that is not None or "" + return bool(location) + + def __repr__(self): + return f"SaveSignatures_SigFile('{self.location}')" + + def open(self): + pass + + def close(self): + if self.location == '-': + sourmash.save_signatures(self.keep, sys.stdout) + else: + # text mode? encode in utf-8 + mode = "w" + encoding = 'utf-8' + + # compressed? bytes & binary. + if self.compress: + encoding = None + mode = "wb" + + with open(self.location, mode, encoding=encoding) as fp: + sourmash.save_signatures(self.keep, fp, + compression=self.compress) + + def add(self, ss): + super().add(ss) + self.keep.append(ss) + + +class SaveSignatures_ZipFile(Base_SaveSignaturesToLocation): + "Save compressed signatures in an uncompressed Zip file." + def __init__(self, location): + super().__init__(location) + self.storage = None + + @classmethod + def matches(cls, location): + "anything ending in .zip" + if location: + return location.endswith('.zip') + + def __repr__(self): + return f"SaveSignatures_ZipFile('{self.location}')" + + def close(self): + # finish constructing manifest object & save + manifest = CollectionManifest(self.manifest_rows) + manifest_name = "SOURMASH-MANIFEST.csv" + + manifest_fp = StringIO() + manifest.write_to_csv(manifest_fp, write_header=True) + manifest_data = manifest_fp.getvalue().encode("utf-8") + + self.storage.save(manifest_name, manifest_data, overwrite=True, + compress=True) + self.storage.flush() + self.storage.close() + + def open(self): + from .sbt_storage import ZipStorage + + do_create = True + if os.path.exists(self.location): + do_create = False + + storage = None + try: + storage = ZipStorage(self.location, mode="w") + except zipfile.BadZipFile: + pass + + if storage is None: + raise ValueError(f"File '{self.location}' cannot be opened as a zip file.") + + if not storage.subdir: + storage.subdir = 'signatures' + + # now, try to load manifest + try: + manifest_data = storage.load('SOURMASH-MANIFEST.csv') + except (FileNotFoundError, KeyError): + # if file already exists must have manifest... + if not do_create: + raise ValueError(f"Cannot add to existing zipfile '{self.location}' without a manifest") + self.manifest_rows = [] + else: + # success! decode manifest_data, create manifest rows => append. + manifest_data = manifest_data.decode('utf-8') + manifest_fp = StringIO(manifest_data) + manifest = CollectionManifest.load_from_csv(manifest_fp) + self.manifest_rows = list(manifest._select()) + + self.storage = storage + + def _exists(self, name): + try: + self.storage.load(name) + return True + except KeyError: + return False + + def add(self, add_sig): + if not self.storage: + raise ValueError("this output is not open") + + for ss in _get_signatures_from_rust([add_sig]): + buf = sigmod.save_signatures([ss], compression=1) + md5 = ss.md5sum() + + storage = self.storage + path = f'{storage.subdir}/{md5}.sig.gz' + location = storage.save(path, buf) + + # update manifest + row = CollectionManifest.make_manifest_row(ss, location, + include_signature=False) + self.manifest_rows.append(row) + super().add(ss) + + +_save_classes = [ + (10, SaveSignatures_NoOutput), + (20, SaveSignatures_Directory), + (30, SaveSignatures_ZipFile), + (40, SaveSignatures_SqliteIndex), + (1000, SaveSignatures_SigFile), +] diff --git a/src/sourmash/sbt.py b/src/sourmash/sbt.py index 72cad16c30..3ad36ebe1f 100644 --- a/src/sourmash/sbt.py +++ b/src/sourmash/sbt.py @@ -36,7 +36,7 @@ NodePos = namedtuple("NodePos", ["pos", "node"]) -class GraphFactory(object): +class GraphFactory: """Build new nodegraphs (Bloom filters) of a specific (fixed) size. Parameters @@ -1290,7 +1290,7 @@ def combine(self, other): return self -class Node(object): +class Node: "Internal node of SBT." def __init__(self, factory, name=None, path=None, storage=None): @@ -1349,7 +1349,7 @@ def update(self, parent): parent.metadata['min_n_below'] = min_n_below -class Leaf(object): +class Leaf: def __init__(self, metadata, data=None, name=None, storage=None, path=None): self.metadata = metadata diff --git a/src/sourmash/sbt_storage.py b/src/sourmash/sbt_storage.py index 398e11c877..a22e782d69 100644 --- a/src/sourmash/sbt_storage.py +++ b/src/sourmash/sbt_storage.py @@ -323,10 +323,14 @@ def load(self, path): def close(self): # TODO: this is not ideal; checking for zipfile.fp is looking at # internal implementation details from CPython... - if self.zipfile is not None or self.bufferzip is not None: - self.flush(keep_closed=True) - self.zipfile.close() - self.zipfile = None + + # might not have self.zipfile if was invalid zipfile and __init__ + # failed. + if hasattr(self, 'zipfile'): + if self.zipfile is not None or self.bufferzip is not None: + self.flush(keep_closed=True) + self.zipfile.close() + self.zipfile = None def flush(self, *, keep_closed=False): # This is a bit complicated, but we have to deal with new data diff --git a/src/sourmash/search.py b/src/sourmash/search.py index 5a86fa8d85..a019c2bbc2 100644 --- a/src/sourmash/search.py +++ b/src/sourmash/search.py @@ -11,6 +11,31 @@ from .sketchcomparison import FracMinHashComparison, NumMinHashComparison +def calc_threshold_from_bp(threshold_bp, scaled, query_size): + """ + Convert threshold_bp (threshold in estimated bp) to + fraction of query & minimum number of hashes needed. + """ + threshold = 0.0 + n_threshold_hashes = 0 + + if threshold_bp: + if threshold_bp < 0: + raise TypeError("threshold_bp must be non-negative") + + # if we have a threshold_bp of N, then that amounts to N/scaled + # hashes: + n_threshold_hashes = float(threshold_bp) / scaled + + # that then requires the following containment: + threshold = n_threshold_hashes / query_size + + # is it too high to ever match? + if threshold > 1.0: + raise ValueError("requested threshold_bp is unattainable with this query") + return threshold, n_threshold_hashes + + class SearchType(Enum): JACCARD = 1 CONTAINMENT = 2 @@ -43,8 +68,8 @@ def make_jaccard_search_query(*, return search_obj -def make_gather_query(query_mh, threshold_bp, *, best_only=True): - "Make a search object for gather." +def make_containment_query(query_mh, threshold_bp, *, best_only=True): + "Make a search object for containment, with threshold_bp." if not query_mh: raise ValueError("query is empty!?") @@ -53,21 +78,7 @@ def make_gather_query(query_mh, threshold_bp, *, best_only=True): raise TypeError("query signature must be calculated with scaled") # are we setting a threshold? - threshold = 0 - if threshold_bp: - if threshold_bp < 0: - raise TypeError("threshold_bp must be non-negative") - - # if we have a threshold_bp of N, then that amounts to N/scaled - # hashes: - n_threshold_hashes = threshold_bp / scaled - - # that then requires the following containment: - threshold = n_threshold_hashes / len(query_mh) - - # is it too high to ever match? if so, exit. - if threshold > 1.0: - raise ValueError("requested threshold_bp is unattainable with this query") + threshold, _ = calc_threshold_from_bp(threshold_bp, scaled, len(query_mh)) if best_only: search_obj = JaccardSearchBestOnly(SearchType.CONTAINMENT, @@ -224,8 +235,8 @@ def get_cmpinfo(self): # could define in PrefetchResult instead, same reasoning as above self.query_abundance = self.mh1.track_abundance self.match_abundance = self.mh2.track_abundance - self.query_n_hashes = len(self.mh1.hashes) - self.match_n_hashes = len(self.mh2.hashes) + self.query_n_hashes = len(self.mh1) + self.match_n_hashes = len(self.mh2) @property def pass_threshold(self): @@ -308,15 +319,15 @@ def check_similarity(self): def estimate_search_ani(self): #future: could estimate ANI from abund searches if we want (use query containment?) if self.cmp_scaled is None: - raise TypeError("ANI can only be estimated from scaled signatures.") + raise TypeError("Error: ANI can only be estimated from scaled signatures.") if self.searchtype == SearchType.CONTAINMENT: - self.cmp.estimate_mh1_containment_ani(containment = self.similarity) - self.ani = self.cmp.mh1_containment_ani + self.cmp.estimate_ani_from_mh1_containment_in_mh2(containment = self.similarity) + self.ani = self.cmp.ani_from_mh1_containment_in_mh2 if self.estimate_ani_ci: - self.ani_low = self.cmp.mh1_containment_ani_low - self.ani_high = self.cmp.mh1_containment_ani_high + self.ani_low = self.cmp.ani_from_mh1_containment_in_mh2_low + self.ani_high = self.cmp.ani_from_mh1_containment_in_mh2_high elif self.searchtype == SearchType.MAX_CONTAINMENT: - self.cmp.estimate_max_containment_ani(max_containment = self.similarity) + self.cmp.estimate_max_containment_ani() self.ani = self.cmp.max_containment_ani if self.estimate_ani_ci: self.ani_low = self.cmp.max_containment_ani_low @@ -366,8 +377,8 @@ def init_sigcomparison(self): def estimate_containment_ani(self): self.cmp.estimate_all_containment_ani() - self.query_containment_ani = self.cmp.mh1_containment_ani - self.match_containment_ani = self.cmp.mh2_containment_ani + self.query_containment_ani = self.cmp.ani_from_mh1_containment_in_mh2 + self.match_containment_ani = self.cmp.ani_from_mh2_containment_in_mh1 self.average_containment_ani = self.cmp.avg_containment_ani self.max_containment_ani = self.cmp.max_containment_ani self.potential_false_negative = self.cmp.potential_false_negative @@ -375,16 +386,16 @@ def estimate_containment_ani(self): self.handle_ani_ci() def handle_ani_ci(self): - self.query_containment_ani_low = self.cmp.mh1_containment_ani_low - self.query_containment_ani_high = self.cmp.mh1_containment_ani_high - self.match_containment_ani_low = self.cmp.mh2_containment_ani_low - self.match_containment_ani_high = self.cmp.mh2_containment_ani_high + self.query_containment_ani_low = self.cmp.ani_from_mh1_containment_in_mh2_low + self.query_containment_ani_high = self.cmp.ani_from_mh1_containment_in_mh2_high + self.match_containment_ani_low = self.cmp.ani_from_mh2_containment_in_mh1_low + self.match_containment_ani_high = self.cmp.ani_from_mh2_containment_in_mh1_high def build_prefetch_result(self): # unique prefetch values self.jaccard = self.cmp.jaccard - self.f_query_match = self.cmp.mh2_containment #db_mh.contained_by(query_mh) - self.f_match_query = self.cmp.mh1_containment #query_mh.contained_by(db_mh) + self.f_query_match = self.cmp.mh2_containment_in_mh1 #db_mh.contained_by(query_mh) + self.f_match_query = self.cmp.mh1_containment_in_mh2 #query_mh.contained_by(db_mh) # set write columns for prefetch result self.write_cols = self.prefetch_write_cols if self.estimate_ani_ci: @@ -417,9 +428,10 @@ def prefetchresultdict(self): class GatherResult(PrefetchResult): gather_querymh: MinHash = None gather_result_rank: int = None - total_abund: int = None orig_query_len: int = None orig_query_abunds: list = None + sum_weighted_found: int = None + total_weighted_hashes: int = None gather_write_cols = ['intersect_bp', 'f_orig_query', 'f_match', 'f_unique_to_query', 'f_unique_weighted','average_abund', 'median_abund', 'std_abund', 'filename', # here we use 'filename' @@ -427,7 +439,9 @@ class GatherResult(PrefetchResult): 'remaining_bp', 'query_filename', 'query_name', 'query_md5', 'query_bp', 'ksize', 'moltype', 'scaled', 'query_n_hashes', 'query_abundance', 'query_containment_ani', 'match_containment_ani', 'average_containment_ani', 'max_containment_ani', - 'potential_false_negative'] + 'potential_false_negative', + 'n_unique_weighted_found', 'sum_weighted_found', + 'total_weighted_hashes'] ci_cols = ["query_containment_ani_low", "query_containment_ani_high", "match_containment_ani_low", "match_containment_ani_high"] @@ -446,8 +460,8 @@ def check_gatherresult_input(self): raise ValueError("Error: must provide current gather sketch (remaining hashes) for GatherResult") if self.gather_result_rank is None: raise ValueError("Error: must provide 'gather_result_rank' to GatherResult") - if not self.total_abund: # catch total_abund = 0 as well - raise ValueError("Error: must provide sum of all abundances ('total_abund') to GatherResult") + if not self.total_weighted_hashes: # catch total_weighted_hashes = 0 as well + raise ValueError("Error: must provide sum of all abundances ('total_weighted_hashes') to GatherResult") if not self.orig_query_abunds: raise ValueError("Error: must provide original query abundances ('orig_query_abunds') to GatherResult") @@ -465,10 +479,10 @@ def build_gather_result(self): self.unique_intersect_bp = self.gather_comparison.total_unique_intersect_hashes # calculate fraction of subject match with orig query - self.f_match_orig = self.cmp.mh2_containment + self.f_match_orig = self.cmp.mh2_containment_in_mh1 # calculate fractions wrt first denominator - genome size - self.f_match = self.gather_comparison.mh2_containment # unique match containment + self.f_match = self.gather_comparison.mh2_containment_in_mh1 # unique match containment self.f_orig_query = len(self.cmp.intersect_mh) / self.orig_query_len assert self.gather_comparison.intersect_mh.contained_by(self.gather_comparison.mh1_cmp) == 1.0 @@ -488,10 +502,12 @@ def build_gather_result(self): self.std_abund = self.query_weighted_unique_intersection.std_abundance # 'query' will be flattened by default. reset track abundance if we have abunds self.query_abundance = self.query_weighted_unique_intersection.track_abundance - # calculate scores weighted by abundances - self.f_unique_weighted = float(self.query_weighted_unique_intersection.sum_abundances) / self.total_abund + # calculate scores weighted by abundances + self.n_unique_weighted_found = self.query_weighted_unique_intersection.sum_abundances + self.f_unique_weighted = self.n_unique_weighted_found / self.total_weighted_hashes else: self.f_unique_weighted = self.f_unique_to_query + self.query_abundance = False def __post_init__(self): self.check_gatherresult_input() @@ -525,8 +541,8 @@ def prefetchresultdict(self): if self.estimate_ani_ci: prefetch_cols = self.prefetch_write_cols_ci self.jaccard = self.cmp.jaccard - self.f_query_match = self.cmp.mh2_containment #db_mh.contained_by(query_mh) - self.f_match_query = self.cmp.mh1_containment #query_mh.contained_by(db_mh) + self.f_query_match = self.cmp.mh2_containment_in_mh1 #db_mh.contained_by(query_mh) + self.f_match_query = self.cmp.mh1_containment_in_mh2 #query_mh.contained_by(db_mh) self.prep_prefetch_result() return self.to_write(columns=prefetch_cols) @@ -535,7 +551,7 @@ def format_bp(bp): "Pretty-print bp information." bp = float(bp) if bp < 500: - return '{:.0f} bp '.format(bp) + return '{:.0f} bp'.format(bp) elif bp <= 500e3: return '{:.1f} kbp'.format(round(bp / 1e3, 1)) elif bp < 500e6: @@ -621,7 +637,7 @@ def _find_best(counters, query, threshold_bp): # find the best score across multiple counters, without consuming for counter in counters: - result = counter.peek(query.minhash, threshold_bp) + result = counter.peek(query.minhash, threshold_bp=threshold_bp) if result: (sr, intersect_mh) = result @@ -643,7 +659,7 @@ class GatherDatabases: "Iterator object for doing gather/min-set-cov." def __init__(self, query, counters, *, - threshold_bp=0, ignore_abundance=False, noident_mh=None, estimate_ani_ci=False): + threshold_bp=0, ignore_abundance=False, noident_mh=None, ident_mh=None, estimate_ani_ci=False): # track original query information for later usage? track_abundance = query.minhash.track_abundance and not ignore_abundance self.orig_query = query @@ -664,11 +680,17 @@ def __init__(self, query, counters, *, noident_mh = query_mh.copy_and_clear() self.noident_mh = noident_mh.to_frozen() - query_mh = query_mh.to_mutable() - query_mh.remove_many(noident_mh) + if ident_mh is None: + query_mh = query_mh.to_mutable() + query_mh.remove_many(noident_mh) + else: + query_mh = ident_mh.to_mutable() orig_query_mh = query_mh.flatten() - query.minhash = orig_query_mh.to_mutable() + + # query.minhash will be assigned to repeatedly in gather; make mutable. + query = query.to_mutable() + query.minhash = orig_query_mh cmp_scaled = query.minhash.scaled # initialize with resolution of query @@ -761,31 +783,32 @@ def __next__(self): new_query_mh.remove_many(found_mh) new_query = SourmashSignature(new_query_mh) - # compute weighted_missed for remaining query hashes + # compute weighted information for remaining query hashes query_hashes = set(query_mh.hashes) - set(found_mh.hashes) - weighted_missed = sum((orig_query_abunds[k] for k in query_hashes)) - weighted_missed += self.noident_query_sum_abunds - weighted_missed /= sum_abunds + n_weighted_missed = sum((orig_query_abunds[k] for k in query_hashes)) + n_weighted_missed += self.noident_query_sum_abunds + sum_weighted_found = sum_abunds - n_weighted_missed # build a GatherResult result = GatherResult(self.orig_query, best_match, cmp_scaled=scaled, filename=filename, gather_result_rank=self.result_n, - total_abund= sum_abunds, gather_querymh=query.minhash, ignore_abundance= not track_abundance, threshold_bp=threshold_bp, orig_query_len=orig_query_len, orig_query_abunds = self.orig_query_abunds, estimate_ani_ci=self.estimate_ani_ci, + sum_weighted_found=sum_weighted_found, + total_weighted_hashes=sum_abunds, ) self.result_n += 1 self.query = new_query self.orig_query_mh = orig_query_mh - return result, weighted_missed + return result ### diff --git a/src/sourmash/sig/__init__.py b/src/sourmash/sig/__init__.py index c3f696f8fd..0fafe39246 100644 --- a/src/sourmash/sig/__init__.py +++ b/src/sourmash/sig/__init__.py @@ -1,2 +1,2 @@ -from .__main__ import main +from .__main__ import * # bring all functions into top-level from . import grep diff --git a/src/sourmash/sig/__main__.py b/src/sourmash/sig/__main__.py index 8bec700db5..7336f7ac79 100644 --- a/src/sourmash/sig/__main__.py +++ b/src/sourmash/sig/__main__.py @@ -83,7 +83,7 @@ def cat(args): """ concatenate all signatures into one file. """ - set_quiet(args.quiet) + set_quiet(args.quiet, args.debug) moltype = sourmash_args.calculate_moltype(args) picklist = sourmash_args.load_picklist(args) pattern_search = sourmash_args.load_include_exclude_db_patterns(args) @@ -139,8 +139,8 @@ def split(args): _extend_signatures_with_from_file(args) output_names = set() - output_scaled_template = '{md5sum}.k={ksize}.scaled={scaled}.{moltype}.dup={dup}.{basename}.sig' - output_num_template = '{md5sum}.k={ksize}.num={num}.{moltype}.dup={dup}.{basename}.sig' + output_scaled_template = '{md5sum}.k={ksize}.scaled={scaled}.{moltype}.dup={dup}.{basename}' + args.extension + output_num_template = '{md5sum}.k={ksize}.num={num}.{moltype}.dup={dup}.{basename}' + args.extension if args.output_dir: if not os.path.exists(args.output_dir): @@ -195,8 +195,8 @@ def split(args): notify(f"** overwriting existing file {format(output_name)}") # save! - with open(output_name, 'wt') as outfp: - sourmash.save_signatures([sig], outfp) + with sourmash_args.SaveSignaturesToLocation(output_name) as save_sigs: + save_sigs.add(sig) notify(f'writing sig to {output_name}') notify(f'loaded and split {len(progress)} signatures total.') @@ -442,8 +442,8 @@ def merge(args): merged_sigobj = sourmash.SourmashSignature(mh, name=args.name) - with FileOutput(args.output, 'wt') as fp: - sourmash.save_signatures([merged_sigobj], fp=fp) + with sourmash_args.SaveSignaturesToLocation(args.output) as save_sigs: + save_sigs.add(merged_sigobj) notify(f'loaded and merged {len(progress)} signatures') @@ -488,6 +488,10 @@ def intersect(args): mins.intersection_update(sigobj.minhash.hashes) + if first_sig is None: + notify("no signatures provided to intersect!?") + sys.exit(-1) + # forcibly turn off track_abundance, unless --abundances-from set. intersect_mh = first_sig.minhash.copy_and_clear().flatten() intersect_mh.add_many(mins) @@ -505,8 +509,8 @@ def intersect(args): intersect_mh = intersect_mh.inflate(abund_sig.minhash) intersect_sigobj = sourmash.SourmashSignature(intersect_mh) - with FileOutput(args.output, 'wt') as fp: - sourmash.save_signatures([intersect_sigobj], fp=fp) + with sourmash_args.SaveSignaturesToLocation(args.output) as save_sigs: + save_sigs.add(intersect_sigobj) notify(f'loaded and intersected {len(progress)} signatures') if picklist: @@ -623,8 +627,8 @@ def subtract(args): subtract_sigobj = sourmash.SourmashSignature(subtract_mh) - with FileOutput(args.output, 'wt') as fp: - sourmash.save_signatures([subtract_sigobj], fp=fp) + with sourmash_args.SaveSignaturesToLocation(args.output) as save_sigs: + save_sigs.add(subtract_sigobj) notify(f'loaded and subtracted {len(progress)} signatures') @@ -654,6 +658,7 @@ def rename(args): pattern=pattern_search) for sigobj, sigloc in loader: + sigobj = sigobj.to_mutable() sigobj._name = args.name save_sigs.add(sigobj) @@ -782,6 +787,7 @@ def filter(args): filtered_mh = mh.copy_and_clear() filtered_mh.set_abundances(abunds2) + ss = ss.to_mutable() ss.minhash = filtered_mh save_sigs.add(ss) @@ -823,6 +829,7 @@ def flatten(args): if args.name not in ss.name: continue # skip + ss = ss.to_mutable() ss.minhash = ss.minhash.flatten() save_sigs.add(ss) @@ -865,7 +872,8 @@ def downsample(args): yield_all_files=args.force, force=args.force) for ss, sigloc in loader: - mh = ss.minhash + sigobj = ss.to_mutable() + mh = sigobj.minhash if args.scaled: # downsample scaled to scaled? straightforward. @@ -894,10 +902,9 @@ def downsample(args): mh_new = mh.copy() _set_num_scaled(mh_new, args.num_hashes, 0) - ss.minhash = mh_new - # save! - save_sigs.add(ss) + sigobj.minhash = mh_new + save_sigs.add(sigobj) save_sigs.close() @@ -960,8 +967,8 @@ def sig_import(args): siglist.append(s) notify(f'saving {len(siglist)} signatures to JSON') - with FileOutput(args.output, 'wt') as fp: - sourmash.save_signatures(siglist, fp) + with sourmash_args.SaveSignaturesToLocation(args.output) as save_sigs: + save_sigs.add_many(siglist) def export(args): @@ -1366,9 +1373,8 @@ def check(args): # go through the input file and pick out missing rows. n_input = 0 n_output = 0 - with open(pickfile, newline='') as csvfp: - r = csv.DictReader(csvfp) + with sourmash_args.FileInputCSV(pickfile) as r: with open(args.output_missing, "w", newline='') as outfp: w = csv.DictWriter(outfp, fieldnames=r.fieldnames) w.writeheader() diff --git a/src/sourmash/signature.py b/src/sourmash/signature.py index cdd3e5ea71..1fd34d35e6 100644 --- a/src/sourmash/signature.py +++ b/src/sourmash/signature.py @@ -6,6 +6,7 @@ import os import weakref from enum import Enum +import contextlib from .logging import error from . import MinHash @@ -225,10 +226,8 @@ def __reduce__(self): ) def __copy__(self): - mh = self.minhash - mh = mh.to_frozen() a = SourmashSignature( - mh, + self.minhash, name=self.name, filename=self.filename, ) @@ -236,6 +235,86 @@ def __copy__(self): copy = __copy__ + def to_frozen(self): + "Return a frozen copy of this signature." + new_ss = self.copy() + new_ss.__class__ = FrozenSourmashSignature + return new_ss + + def to_mutable(self): + "Return a mutable copy of this signature." + return self.copy() + + def into_frozen(self): + "Freeze this signature, preventing attribute changes." + # this will always be the case b/c minhash property returns FrozenMH: + # assert isinstance(self.minhash, FrozenMinHash) + self.__class__ = FrozenSourmashSignature + + +class FrozenSourmashSignature(SourmashSignature): + "Frozen (immutable) signature class." + + @SourmashSignature.minhash.setter + def minhash(self, value): + raise ValueError("cannot set .minhash on FrozenSourmashSignature") + + @SourmashSignature._name.setter + def _name(self, value): + raise ValueError("cannot set ._name on FrozenSourmashSignature") + + @SourmashSignature.name.setter + def name(self, value): + raise ValueError("cannot set .name on FrozenSourmashSignature") + + @SourmashSignature.filename.setter + def filename(self, value): + raise ValueError("cannot set .filename on FrozenSourmashSignature") + + def add_sequence(self, sequence, force=False): + raise ValueError("cannot add sequence data to FrozenSourmashSignature") + + def add_protein(self, sequence): + raise ValueError("cannot add protein sequence to FrozenSourmashSignature") + + def __copy__(self): + return self + copy = __copy__ + + def to_frozen(self): + "Return a frozen copy of this signature." + return self + + def to_mutable(self): + "Turn this object into a mutable object." + mut = SourmashSignature.__new__(SourmashSignature) + state_tup = self.__getstate__() + mut.__setstate__(state_tup) + return mut + + def into_frozen(self): + "Freeze this signature, preventing attribute changes." + self.__class__ = FrozenSourmashSignature + + @contextlib.contextmanager + def update(self): + """Make a mutable copy of this signature for modification, then freeze. + + This is a context manager that implements: + + new_sig = this_sig.copy() + new_sig.to_mutable() + # modify new_sig + new_sig.into_frozen() + + This could be made more efficient by _not_ copying the signature, + but that is non-intuitive and leads to hard-to-find bugs. + """ + new_copy = self.to_mutable() + yield new_copy + new_copy.into_frozen() + + def _detect_input_type(data): """\ Determine how to load input from `data`. Returns SigInput enum. @@ -272,7 +351,7 @@ def load_signatures( ): """Load a JSON string with signatures into classes. - Returns list of SourmashSignature objects. + Returns iterator over SourmashSignature objects. Note, the order is not necessarily the same as what is in the source file. """ @@ -342,7 +421,7 @@ def load_signatures( sigs.append(sig) for sig in sigs: - yield sig + yield sig.to_frozen() except Exception as e: if do_raise: diff --git a/src/sourmash/sketchcomparison.py b/src/sourmash/sketchcomparison.py index 5de42b431f..db36d20ac3 100644 --- a/src/sourmash/sketchcomparison.py +++ b/src/sourmash/sketchcomparison.py @@ -124,10 +124,10 @@ def total_unique_intersect_hashes(self): return len(self.intersect_mh) * self.cmp_scaled # + (ksize-1) #for bp estimation @property - def mh1_containment(self): + def mh1_containment_in_mh2(self): return self.mh1_cmp.contained_by(self.mh2_cmp) - def estimate_mh1_containment_ani(self, containment = None): + def estimate_ani_from_mh1_containment_in_mh2(self, containment = None): # build result once m1_cani = self.mh1_cmp.containment_ani(self.mh2_cmp, containment=containment, @@ -135,30 +135,30 @@ def estimate_mh1_containment_ani(self, containment = None): estimate_ci=self.estimate_ani_ci) # prob_threshold=self.pfn_threshold) # propagate params - self.mh1_containment_ani = m1_cani.ani + self.ani_from_mh1_containment_in_mh2 = m1_cani.ani if m1_cani.p_exceeds_threshold: # only update if True self.potential_false_negative = True if self.estimate_ani_ci: - self.mh1_containment_ani_low = m1_cani.ani_low - self.mh1_containment_ani_high = m1_cani.ani_high + self.ani_from_mh1_containment_in_mh2_low = m1_cani.ani_low + self.ani_from_mh1_containment_in_mh2_high = m1_cani.ani_high @property - def mh2_containment(self): + def mh2_containment_in_mh1(self): return self.mh2_cmp.contained_by(self.mh1_cmp) - def estimate_mh2_containment_ani(self, containment=None): + def estimate_ani_from_mh2_containment_in_mh1(self, containment=None): m2_cani = self.mh2_cmp.containment_ani(self.mh1_cmp, containment=containment, confidence=self.ani_confidence, estimate_ci=self.estimate_ani_ci) # prob_threshold=self.pfn_threshold) - self.mh2_containment_ani = m2_cani.ani + self.ani_from_mh2_containment_in_mh1 = m2_cani.ani if m2_cani.p_exceeds_threshold: self.potential_false_negative = True if self.estimate_ani_ci: - self.mh2_containment_ani_low = m2_cani.ani_low - self.mh2_containment_ani_high = m2_cani.ani_high + self.ani_from_mh2_containment_in_mh1_low = m2_cani.ani_low + self.ani_from_mh2_containment_in_mh1_high = m2_cani.ani_high @property def max_containment(self): @@ -185,22 +185,22 @@ def avg_containment(self): @property def avg_containment_ani(self): "Returns single average_containment_ani value. Sets self.potential_false_negative internally." - self.estimate_mh1_containment_ani() - self.estimate_mh2_containment_ani() - if any([self.mh1_containment_ani is None, self.mh2_containment_ani is None]): + self.estimate_ani_from_mh1_containment_in_mh2() + self.estimate_ani_from_mh2_containment_in_mh1() + if any([self.ani_from_mh1_containment_in_mh2 is None, self.ani_from_mh2_containment_in_mh1 is None]): return None else: - return (self.mh1_containment_ani + self.mh2_containment_ani)/2 + return (self.ani_from_mh1_containment_in_mh2 + self.ani_from_mh2_containment_in_mh1)/2 def estimate_all_containment_ani(self): "Estimate all containment ANI values." - self.estimate_mh1_containment_ani() - self.estimate_mh2_containment_ani() - if any([self.mh1_containment_ani is None, self.mh2_containment_ani is None]): + self.estimate_ani_from_mh1_containment_in_mh2() + self.estimate_ani_from_mh2_containment_in_mh1() + if any([self.ani_from_mh1_containment_in_mh2 is None, self.ani_from_mh2_containment_in_mh1 is None]): # self.estimate_max_containment_ani() self.max_containment_ani = None else: - self.max_containment_ani = max([self.mh1_containment_ani, self.mh2_containment_ani]) + self.max_containment_ani = max([self.ani_from_mh1_containment_in_mh2, self.ani_from_mh2_containment_in_mh1]) def weighted_intersection(self, from_mh=None, from_abundD={}): # map abundances to all intersection hashes. diff --git a/src/sourmash/sourmash_args.py b/src/sourmash/sourmash_args.py index a004a51fc4..f0b682f80c 100644 --- a/src/sourmash/sourmash_args.py +++ b/src/sourmash/sourmash_args.py @@ -22,42 +22,37 @@ * load_query_signature(filename, ...) -- load a single signature for query * traverse_find_sigs(filenames, ...) -- find all .sig and .sig.gz files * load_dbs_and_sigs(filenames, query, ...) -- load databases & signatures -* load_file_as_index(filename, ...) -- load a sourmash.Index class -* load_file_as_signatures(filename, ...) -- load a list of signatures * load_pathlist_from_file(filename) -- load a list of paths from a file * load_many_signatures(locations) -- load many signatures from many files * get_manifest(idx) -- retrieve or build a manifest from an Index * class SignatureLoadingProgress - signature loading progress bar +* load_file_as_signatures(filename, ...) -- load a list of signatures signature and file output functionality: -* SaveSignaturesToLocation(filename) - bulk signature output * class FileOutput - file output context manager that deals w/stdout well * class FileOutputCSV - file output context manager for CSV files + +misc support: +* FileInputCSV - context manager for reading CSVs """ import sys import os -from enum import Enum -import traceback +import csv import gzip -from io import StringIO +from io import TextIOWrapper import re - -import screed -import sourmash - -from sourmash.sbtmh import load_sbt_index -from sourmash.lca.lca_db import load_single_database -import sourmash.exceptions +import zipfile +import contextlib +import argparse from .logging import notify, error, debug_literal -from .index import (LinearIndex, ZipFileLinearIndex, MultiIndex) -from .index.sqlite_index import load_sqlite_index, SqliteIndex -from . import signature as sigmod +from .index import LinearIndex from .picklist import SignaturePicklist, PickStyle from .manifest import CollectionManifest -import argparse +from .save_load import (SaveSignaturesToLocation, load_file_as_index, + _load_database) DEFAULT_LOAD_K = 31 @@ -67,7 +62,7 @@ def check_scaled_bounds(arg): f = float(arg) if f < 0: - raise argparse.ArgumentTypeError(f"ERROR: scaled value must be positive") + raise argparse.ArgumentTypeError("ERROR: scaled value must be positive") if f < 100: notify('WARNING: scaled value should be >= 100. Continuing anyway.') if f > 1e6: @@ -79,7 +74,7 @@ def check_num_bounds(arg): f = int(arg) if f < 0: - raise argparse.ArgumentTypeError(f"ERROR: num value must be positive") + raise argparse.ArgumentTypeError("ERROR: num value must be positive") if f < 50: notify('WARNING: num value should be >= 50. Continuing anyway.') if f > 50000: @@ -281,31 +276,37 @@ def traverse_find_sigs(filenames, yield_all_files=False): def load_dbs_and_sigs(filenames, query, is_similarity_query, *, - cache_size=None, picklist=None, pattern=None): + cache_size=None, picklist=None, pattern=None, + fail_on_empty_database=False): """ - Load one or more SBTs, LCAs, and/or collections of signatures. - - Check for compatibility with query. + Load one or more Index objects to search - databases, etc. - This is basically a user-focused wrapping of _load_databases. + 'select' on compatibility with query, and apply picklists & patterns. """ query_mh = query.minhash + # set selection parameter for containment containment = True if is_similarity_query: containment = False databases = [] + total_signatures_loaded = 0 + sum_signatures_after_select = 0 for filename in filenames: - notify(f'loading from {filename}...', end='\r') + notify(f"loading from '{filename}'...", end='\r') try: db = _load_database(filename, False, cache_size=cache_size) except ValueError as e: # cannot load database! + notify(f"ERROR on loading from '{filename}':") notify(str(e)) sys.exit(-1) + total_signatures_loaded += len(db) + + # get compatible signatures - moltype/ksize/num/scaled try: db = db.select(moltype=query_mh.moltype, ksize=query_mh.ksize, @@ -316,243 +317,33 @@ def load_dbs_and_sigs(filenames, query, is_similarity_query, *, # incompatible collection specified! notify(f"ERROR: cannot use '{filename}' for this query.") notify(str(exc)) - sys.exit(-1) + if fail_on_empty_database: + sys.exit(-1) + else: + db = LinearIndex([]) # 'select' returns nothing => all signatures filtered out. fail! if not db: notify(f"no compatible signatures found in '{filename}'") - sys.exit(-1) + if fail_on_empty_database: + sys.exit(-1) + + sum_signatures_after_select += len(db) + # last but not least, apply picklist! db = apply_picklist_and_pattern(db, picklist, pattern) databases.append(db) - # calc num loaded info. - n_signatures = 0 - n_databases = 0 - for db in databases: - if db.is_database: - n_databases += 1 - else: - n_signatures += len(db) - - notify(' '*79, end='\r') - if n_signatures and n_databases: - notify(f'loaded {n_signatures} signatures and {n_databases} databases total.') - elif n_signatures and not n_databases: - notify(f'loaded {n_signatures} signatures.') - elif n_databases and not n_signatures: - notify(f'loaded {n_databases} databases.') - - if databases: - print('') - else: - notify('** ERROR: no signatures or databases loaded?') - sys.exit(-1) + # display num loaded/num selected + notify("--") + notify(f"loaded {total_signatures_loaded} total signatures from {len(databases)} locations.") + notify(f"after selecting signatures compatible with search, {sum_signatures_after_select} remain.") + print('') return databases -def _load_stdin(filename, **kwargs): - "Load collection from .sig file streamed in via stdin" - db = None - if filename == '-': - # load as LinearIndex, then pass into MultiIndex to generate a - # manifest. - lidx = LinearIndex.load(sys.stdin, filename='-') - db = MultiIndex.load((lidx,), (None,), parent="-") - - return db - - -def _load_standalone_manifest(filename, **kwargs): - from sourmash.index import StandaloneManifestIndex - idx = StandaloneManifestIndex.load(filename) - return idx - - -def _multiindex_load_from_pathlist(filename, **kwargs): - "Load collection from a list of signature/database files" - db = MultiIndex.load_from_pathlist(filename) - - return db - - -def _multiindex_load_from_path(filename, **kwargs): - "Load collection from a directory." - traverse_yield_all = kwargs['traverse_yield_all'] - db = MultiIndex.load_from_path(filename, traverse_yield_all) - - return db - - -def _load_sbt(filename, **kwargs): - "Load collection from an SBT." - cache_size = kwargs.get('cache_size') - - try: - db = load_sbt_index(filename, cache_size=cache_size) - except (FileNotFoundError, TypeError) as exc: - raise ValueError(exc) - - return db - - -def _load_revindex(filename, **kwargs): - "Load collection from an LCA database/reverse index." - db, _, _ = load_single_database(filename) - return db - - -def _load_sqlite_db(filename, **kwargs): - return load_sqlite_index(filename) - - -def _load_zipfile(filename, **kwargs): - "Load collection from a .zip file." - db = None - if filename.endswith('.zip'): - traverse_yield_all = kwargs['traverse_yield_all'] - try: - db = ZipFileLinearIndex.load(filename, - traverse_yield_all=traverse_yield_all) - except FileNotFoundError as exc: - # turn this into a ValueError => proper exception handling by - # _load_database. - raise ValueError(exc) - - return db - - -# all loader functions, in order. -_loader_functions = [ - ("load from stdin", _load_stdin), - ("load collection from sqlitedb", _load_sqlite_db), - ("load from standalone manifest", _load_standalone_manifest), - ("load from path (file or directory)", _multiindex_load_from_path), - ("load from file list", _multiindex_load_from_pathlist), - ("load SBT", _load_sbt), - ("load revindex", _load_revindex), - ("load collection from zipfile", _load_zipfile), - ] - - -def _load_database(filename, traverse_yield_all, *, cache_size=None): - """Load file as a database - list of signatures, LCA, SBT, etc. - - Return Index object. - - This is an internal function used by other functions in sourmash_args. - """ - loaded = False - - # iterate through loader functions, trying them all. Catch ValueError - # but nothing else. - for n, (desc, load_fn) in enumerate(_loader_functions): - try: - debug_literal(f"_load_databases: trying loader fn {n} '{desc}'") - db = load_fn(filename, - traverse_yield_all=traverse_yield_all, - cache_size=cache_size) - except ValueError: - debug_literal(f"_load_databases: FAIL on fn {n} {desc}.") - debug_literal(traceback.format_exc()) - - if db is not None: - loaded = True - debug_literal("_load_databases: success!") - break - - # check to see if it's a FASTA/FASTQ record (i.e. screed loadable) - # so we can provide a better error message to users. - if not loaded: - successful_screed_load = False - it = None - try: - # CTB: could be kind of time consuming for a big record, but at the - # moment screed doesn't expose format detection cleanly. - with screed.open(filename) as it: - _ = next(iter(it)) - successful_screed_load = True - except: - pass - - if successful_screed_load: - raise ValueError(f"Error while reading signatures from '{filename}' - got sequences instead! Is this a FASTA/FASTQ file?") - - if not loaded: - raise ValueError(f"Error while reading signatures from '{filename}'.") - - if loaded: # this is a bit redundant but safe > sorry - assert db is not None - - return db - - -def load_file_as_index(filename, *, yield_all_files=False): - """Load 'filename' as a database; generic database loader. - - If 'filename' contains an SBT or LCA indexed database, or a regular - Zip file, will return the appropriate objects. If a Zip file and - yield_all_files=True, will try to load all files within zip, not just - .sig files. - - If 'filename' is a JSON file containing one or more signatures, will - return an Index object containing those signatures. - - If 'filename' is a directory, will load *.sig underneath - this directory into an Index object. If yield_all_files=True, will - attempt to load all files. - """ - return _load_database(filename, yield_all_files) - - -def load_file_as_signatures(filename, *, select_moltype=None, ksize=None, - picklist=None, - yield_all_files=False, - progress=None, - pattern=None, - _use_manifest=True): - """Load 'filename' as a collection of signatures. Return an iterable. - - If 'filename' contains an SBT or LCA indexed database, or a regular - Zip file, will return a signatures() generator. If a Zip file and - yield_all_files=True, will try to load all files within zip, not just - .sig files. - - If 'filename' is a JSON file containing one or more signatures, will - return a list of those signatures. - - If 'filename' is a directory, will load *.sig - underneath this directory into a list of signatures. If - yield_all_files=True, will attempt to load all files. - - Applies selector function if select_moltype, ksize or picklist are given. - - 'pattern' is a function that returns True on matching values. - """ - if progress: - progress.notify(filename) - - db = _load_database(filename, yield_all_files) - - # test fixture ;) - if not _use_manifest and db.manifest: - db.manifest = None - - db = db.select(moltype=select_moltype, ksize=ksize) - - # apply pattern search & picklist - db = apply_picklist_and_pattern(db, picklist, pattern) - - loader = db.signatures() - - if progress is not None: - return progress.start_file(filename, loader) - else: - return loader - - def load_pathlist_from_file(filename): "Load a list-of-files text file." try: @@ -573,7 +364,7 @@ def load_pathlist_from_file(filename): return file_list -class FileOutput(object): +class FileOutput: """A context manager for file outputs that handles sys.stdout gracefully. Usage: @@ -651,11 +442,123 @@ def __init__(self, filename): def open(self): if self.filename == '-' or self.filename is None: return sys.stdout - self.fp = open(self.filename, 'w', newline='') + if self.filename.endswith('.gz'): + self.fp = gzip.open(self.filename, 'wt', newline='') + else: + self.fp = open(self.filename, 'w', newline='') return self.fp -class SignatureLoadingProgress(object): +class _DictReader_with_version: + """A version of csv.DictReader that allows a comment line with a version, + e.g. + + # SOURMASH-MANIFEST-VERSION: 1.0 + + The version is stored as a 2-tuple in the 'version_info' attribute. + """ + def __init__(self, textfp, *, delimiter=','): + self.version_info = [] + + # is there a '#' in the raw buffer pos 0? + ch = textfp.buffer.peek(1) + + try: + ch = ch.decode('utf-8') + except UnicodeDecodeError: + raise csv.Error("unable to read CSV file") + + # yes - read a line from the text buffer => parse + if ch.startswith('#'): + line = textfp.readline() + assert line.startswith('# '), line + + # note, this can set version_info to lots of different things. + # revisit later, I guess. CTB. + self.version_info = line[2:].strip().split(': ', 2) + + # build a DictReader from the remaining stream + self.reader = csv.DictReader(textfp, delimiter=delimiter) + self.fieldnames = self.reader.fieldnames + + def __iter__(self): + for row in self.reader: + yield row + + +@contextlib.contextmanager +def FileInputCSV(filename, *, encoding='utf-8', default_csv_name=None, + zipfile_obj=None, delimiter=','): + """A context manager for reading in CSV files in gzip, zip or text format. + + Assumes comma delimiter, and uses csv.DictReader. + + Note: does not support stdin. + + Note: it seems surprisingly hard to write code that generically handles + any file handle being passed in; the manifest loading code, in particular, + uses ZipStorage.load => StringIO obj, which doesn't support peek etc. + So for now, this context manager is focused on situations where it owns + the file handle (opens/closes the file). + """ + fp = None + + if zipfile_obj and not default_csv_name: + raise ValueError("must provide default_csv_name with a zipfile_obj") + + # first, try to load 'default_csv_name' from a zipfile: + if default_csv_name: + # were we given a zipfile obj? + if zipfile_obj: + try: + zi = zipfile_obj.getinfo(default_csv_name) + with zipfile_obj.open(zi) as fp: + textfp = TextIOWrapper(fp, + encoding=encoding, + newline="") + r = _DictReader_with_version(textfp, delimiter=delimiter) + yield r + except (zipfile.BadZipFile, KeyError): + pass # uh oh, we were given a zipfile_obj and it FAILED. + + # no matter what, if given zipfile_obj don't try .gz or regular csv + return + else: + try: + with zipfile.ZipFile(filename, 'r') as zip_fp: + zi = zip_fp.getinfo(default_csv_name) + with zip_fp.open(zi) as fp: + textfp = TextIOWrapper(fp, + encoding=encoding, + newline="") + r = _DictReader_with_version(textfp, delimiter=delimiter) + yield r + + # if we got this far with no exceptions, we found + # the CSV in the zip file. exit generator! + return + except (zipfile.BadZipFile, KeyError): + # no zipfile_obj => it's ok to continue onwards to .gz + # and regular CSV. + pass + + # ok, not a zip file - try .gz: + try: + with gzip.open(filename, "rt", newline="", encoding=encoding) as fp: + fp.buffer.peek(1) # force exception if not a gzip file + r = _DictReader_with_version(fp, delimiter=delimiter) + yield r + return + except gzip.BadGzipFile: + pass + + # neither zip nor gz; regular file! + with open(filename, 'rt', newline="", encoding=encoding) as fp: + r = _DictReader_with_version(fp, delimiter=delimiter) + yield r + + +class SignatureLoadingProgress: """A wrapper for signature loading progress reporting. Instantiate this class once, and then pass it to load_file_as_signatures @@ -778,8 +681,6 @@ def get_manifest(idx, *, require=True, rebuild=False): In the case where `require=False` and a manifest cannot be built, may return None. Otherwise always returns a manifest. """ - from sourmash.index import CollectionManifest - m = idx.manifest # has one, and don't want to rebuild? easy! return! @@ -805,290 +706,48 @@ def get_manifest(idx, *, require=True, rebuild=False): return m -# -# enum and classes for saving signatures progressively -# - -def _get_signatures_from_rust(siglist): - for ss in siglist: - try: - ss.md5sum() - yield ss - except sourmash.exceptions.Panic: - # this deals with a disconnect between the way Rust - # and Python handle signatures; Python expects one - # minhash (and hence one md5sum) per signature, while - # Rust supports multiple. For now, go through serializing - # and deserializing the signature! See issue #1167 for more. - json_str = sourmash.save_signatures([ss]) - for ss in sourmash.load_signatures(json_str): - yield ss - - -class _BaseSaveSignaturesToLocation: - "Base signature saving class. Track location (if any) and count." - def __init__(self, location): - self.location = location - self.count = 0 - - def __repr__(self): - raise NotImplementedError - - def __len__(self): - return self.count - - def __enter__(self): - "provide context manager functionality" - self.open() - return self - - def __exit__(self, type, value, traceback): - "provide context manager functionality" - self.close() - - def add(self, ss): - self.count += 1 - - def add_many(self, sslist): - for ss in sslist: - self.add(ss) - - -class SaveSignatures_NoOutput(_BaseSaveSignaturesToLocation): - "Do not save signatures." - def __repr__(self): - return 'SaveSignatures_NoOutput()' - - def open(self): - pass - - def close(self): - pass - - -class SaveSignatures_Directory(_BaseSaveSignaturesToLocation): - "Save signatures within a directory, using md5sum names." - def __init__(self, location): - super().__init__(location) - - def __repr__(self): - return f"SaveSignatures_Directory('{self.location}')" - - def close(self): - pass - - def open(self): - try: - os.mkdir(self.location) - except FileExistsError: - pass - except: - notify(f"ERROR: cannot create signature output directory '{self.location}'") - sys.exit(-1) - - def add(self, ss): - super().add(ss) - md5 = ss.md5sum() - - # don't overwrite even if duplicate md5sum - outname = os.path.join(self.location, f"{md5}.sig.gz") - if os.path.exists(outname): - i = 0 - while 1: - outname = os.path.join(self.location, f"{md5}_{i}.sig.gz") - if not os.path.exists(outname): - break - i += 1 - - with gzip.open(outname, "wb") as fp: - sigmod.save_signatures([ss], fp, compression=1) - - -class SaveSignatures_SqliteIndex(_BaseSaveSignaturesToLocation): - "Save signatures within a directory, using md5sum names." - def __init__(self, location): - super().__init__(location) - self.location = location - self.idx = None - self.cursor = None - - def __repr__(self): - return f"SaveSignatures_SqliteIndex('{self.location}')" - - def close(self): - self.idx.commit() - self.cursor.execute('VACUUM') - self.idx.close() - - def open(self): - self.idx = SqliteIndex.create(self.location, append=True) - self.cursor = self.idx.cursor() - - def add(self, add_sig): - for ss in _get_signatures_from_rust([add_sig]): - super().add(ss) - self.idx.insert(ss, cursor=self.cursor, commit=False) - - # commit every 1000 signatures. - if self.count % 1000 == 0: - self.idx.commit() - - -class SaveSignatures_SigFile(_BaseSaveSignaturesToLocation): - "Save signatures to a .sig JSON file." - def __init__(self, location): - super().__init__(location) - self.keep = [] - self.compress = 0 - if self.location.endswith('.gz'): - self.compress = 1 - - def __repr__(self): - return f"SaveSignatures_SigFile('{self.location}')" - - def open(self): - pass - - def close(self): - if self.location == '-': - sourmash.save_signatures(self.keep, sys.stdout) - else: - # text mode? encode in utf-8 - mode = "w" - encoding = 'utf-8' - - # compressed? bytes & binary. - if self.compress: - encoding = None - mode = "wb" - - with open(self.location, mode, encoding=encoding) as fp: - sourmash.save_signatures(self.keep, fp, - compression=self.compress) - - def add(self, ss): - super().add(ss) - self.keep.append(ss) +def load_file_as_signatures(filename, *, select_moltype=None, ksize=None, + picklist=None, + yield_all_files=False, + progress=None, + pattern=None, + _use_manifest=True): + """Load 'filename' as a collection of signatures. Return an iterable. -class SaveSignatures_ZipFile(_BaseSaveSignaturesToLocation): - "Save compressed signatures in an uncompressed Zip file." - def __init__(self, location): - super().__init__(location) - self.storage = None + If 'filename' contains an SBT or LCA indexed database, or a regular + Zip file, will return a signatures() generator. If a Zip file and + yield_all_files=True, will try to load all files within zip, not just + .sig files. - def __repr__(self): - return f"SaveSignatures_ZipFile('{self.location}')" + If 'filename' is a JSON file containing one or more signatures, will + return a list of those signatures. - def close(self): - # finish constructing manifest object & save - manifest = CollectionManifest(self.manifest_rows) - manifest_name = f"SOURMASH-MANIFEST.csv" + If 'filename' is a directory, will load *.sig + underneath this directory into a list of signatures. If + yield_all_files=True, will attempt to load all files. - manifest_fp = StringIO() - manifest.write_to_csv(manifest_fp, write_header=True) - manifest_data = manifest_fp.getvalue().encode("utf-8") + Applies selector function if select_moltype, ksize or picklist are given. - self.storage.save(manifest_name, manifest_data, overwrite=True, - compress=True) - self.storage.flush() - self.storage.close() + 'pattern' is a function that returns True on matching values. + """ + if progress: + progress.notify(filename) - def open(self): - from .sbt_storage import ZipStorage + db = _load_database(filename, yield_all_files) - do_create = True - if os.path.exists(self.location): - do_create = False + # test fixture ;) + if not _use_manifest and db.manifest: + db.manifest = None - storage = ZipStorage(self.location, mode="w") - if not storage.subdir: - storage.subdir = 'signatures' + db = db.select(moltype=select_moltype, ksize=ksize) - # now, try to load manifest - try: - manifest_data = storage.load('SOURMASH-MANIFEST.csv') - except (FileNotFoundError, KeyError): - # if file already exists must have manifest... - if not do_create: - raise ValueError(f"Cannot add to existing zipfile '{self.location}' without a manifest") - self.manifest_rows = [] - else: - # success! decode manifest_data, create manifest rows => append. - manifest_data = manifest_data.decode('utf-8') - manifest_fp = StringIO(manifest_data) - manifest = CollectionManifest.load_from_csv(manifest_fp) - self.manifest_rows = list(manifest._select()) + # apply pattern search & picklist + db = apply_picklist_and_pattern(db, picklist, pattern) - self.storage = storage + loader = db.signatures() - def _exists(self, name): - try: - self.storage.load(name) - return True - except KeyError: - return False - - def add(self, add_sig): - if not self.storage: - raise ValueError("this output is not open") - - for ss in _get_signatures_from_rust([add_sig]): - buf = sigmod.save_signatures([ss], compression=1) - md5 = ss.md5sum() - - storage = self.storage - path = f'{storage.subdir}/{md5}.sig.gz' - location = storage.save(path, buf) - - # update manifest - row = CollectionManifest.make_manifest_row(ss, location, - include_signature=False) - self.manifest_rows.append(row) - super().add(ss) - - -class SigFileSaveType(Enum): - NO_OUTPUT = 0 - SIGFILE = 1 - SIGFILE_GZ = 2 - DIRECTORY = 3 - ZIPFILE = 4 - SQLITEDB = 5 - -_save_classes = { - SigFileSaveType.NO_OUTPUT: SaveSignatures_NoOutput, - SigFileSaveType.SIGFILE: SaveSignatures_SigFile, - SigFileSaveType.SIGFILE_GZ: SaveSignatures_SigFile, - SigFileSaveType.DIRECTORY: SaveSignatures_Directory, - SigFileSaveType.ZIPFILE: SaveSignatures_ZipFile, - SigFileSaveType.SQLITEDB: SaveSignatures_SqliteIndex, -} - - -def SaveSignaturesToLocation(filename, *, force_type=None): - """Create and return an appropriate object for progressive saving of - signatures.""" - save_type = None - if not force_type: - if filename is None: - save_type = SigFileSaveType.NO_OUTPUT - elif filename.endswith('/'): - save_type = SigFileSaveType.DIRECTORY - elif filename.endswith('.gz'): - save_type = SigFileSaveType.SIGFILE_GZ - elif filename.endswith('.zip'): - save_type = SigFileSaveType.ZIPFILE - elif filename.endswith('.sqldb'): - save_type = SigFileSaveType.SQLITEDB - else: - # default to SIGFILE intentionally! - save_type = SigFileSaveType.SIGFILE + if progress is not None: + return progress.start_file(filename, loader) else: - save_type = force_type - - cls = _save_classes.get(save_type) - if cls is None: - raise Exception("invalid save type; this should never happen!?") - - return cls(filename) + return loader diff --git a/src/sourmash/tax/__main__.py b/src/sourmash/tax/__main__.py index 01fc6bbe1c..b6ff3d9dd2 100644 --- a/src/sourmash/tax/__main__.py +++ b/src/sourmash/tax/__main__.py @@ -4,15 +4,16 @@ import sys import csv import os -from collections import defaultdict +from collections import defaultdict, Counter +from dataclasses import asdict, fields +import re import sourmash -from ..sourmash_args import FileOutputCSV -from sourmash.logging import set_quiet, error, notify -from sourmash.lca.lca_utils import display_lineage +from ..sourmash_args import FileOutputCSV, FileInputCSV, FileOutput +from sourmash.logging import set_quiet, error, notify, print_results from . import tax_utils -from .tax_utils import ClassificationResult, MultiLineageDB +from .tax_utils import MultiLineageDB, RankLineageInfo, LINLineageInfo, AnnotateTaxResult usage=''' sourmash taxonomy [] - manipulate/work with taxonomy information. @@ -31,27 +32,32 @@ sourmash taxonomy metagenome -h ''' -# some utils +# outfile utils +_output_type_to_ext = { + 'csv_summary': '.summarized.csv', + 'classification': '.classifications.csv', + 'krona': '.krona.tsv', + 'lineage_summary': '.lineage_summary.tsv', + 'annotate': '.with-lineages.csv', + 'human': '.human.txt', + 'lineage_csv': '.lineage.csv', + 'kreport': ".kreport.txt", + 'lingroup': ".lingroup.tsv", + 'bioboxes': '.bioboxes.profile' + } + def make_outfile(base, output_type, *, output_dir = ""): limit_float_decimals=False if base == "-": limit_float_decimals=True return base, limit_float_decimals - ext="" - if output_type == 'csv_summary': - ext = '.summarized.csv' - elif output_type == 'classification': - ext = '.classifications.csv' - elif output_type == 'krona': - ext = '.krona.tsv' - elif output_type == 'lineage_summary': - ext = '.lineage_summary.tsv' - elif output_type == 'annotate': - ext = '.with-lineages.csv' + + ext = _output_type_to_ext[output_type] + fname = base+ext if output_dir: fname = os.path.join(output_dir, fname) - notify(f"saving `{output_type}` output to {fname}.") + notify(f"saving '{output_type}' output to '{fname}'.") return fname, limit_float_decimals @@ -67,7 +73,7 @@ def metagenome(args): tax_assign = MultiLineageDB.load(args.taxonomy_csv, keep_full_identifiers=args.keep_full_identifiers, keep_identifier_versions=args.keep_identifier_versions, - force=args.force) + force=args.force, lins=args.lins) available_ranks = tax_assign.available_ranks except ValueError as exc: error(f"ERROR: {str(exc)}") @@ -84,53 +90,105 @@ def metagenome(args): # next, collect and load gather results gather_csvs = tax_utils.collect_gather_csvs(args.gather_csv, from_file= args.from_file) try: - gather_results, idents_missed, total_missed, _ = tax_utils.check_and_load_gather_csvs(gather_csvs, tax_assign, force=args.force, - fail_on_missing_taxonomy=args.fail_on_missing_taxonomy) + query_gather_results = tax_utils.check_and_load_gather_csvs(gather_csvs, tax_assign, force=args.force, + fail_on_missing_taxonomy=args.fail_on_missing_taxonomy, + keep_full_identifiers=args.keep_full_identifiers, + keep_identifier_versions = args.keep_identifier_versions, + lins=args.lins, + ) except ValueError as exc: error(f"ERROR: {str(exc)}") sys.exit(-1) - if not gather_results: + if not query_gather_results: notify('No gather results loaded. Exiting.') sys.exit(-1) - # actually summarize at rank - summarized_gather = {} - seen_perfect = set() - for rank in sourmash.lca.taxlist(include_strain=False): - try: - summarized_gather[rank], seen_perfect = tax_utils.summarize_gather_at(rank, tax_assign, gather_results, skip_idents=idents_missed, - keep_full_identifiers=args.keep_full_identifiers, - keep_identifier_versions = args.keep_identifier_versions, - seen_perfect = seen_perfect) + single_query_output_formats = ['csv_summary', 'kreport'] + desired_single_outputs = [] + if len(query_gather_results) > 1: # working with multiple queries + desired_single_outputs = [x for x in args.output_format if x in single_query_output_formats] + if desired_single_outputs: + notify(f"WARNING: found results for multiple gather queries. Can only output multi-query result formats: skipping {', '.join(desired_single_outputs)}") + # remove single query outputs from output format + args.output_format = [x for x in args.output_format if x not in single_query_output_formats] + if not args.output_format: # or do we want to insert `human` here so we always report something? + error(f"ERROR: No output formats remaining.") + sys.exit(-1) + # for each queryResult, actually summarize at rank, reporting any errors that occur. + for queryResult in query_gather_results: + try: + queryResult.build_summarized_result() except ValueError as exc: error(f"ERROR: {str(exc)}") sys.exit(-1) - # write summarized output csv - if "csv_summary" in args.output_format: - summary_outfile, limit_float = make_outfile(args.output_base, "csv_summary", output_dir=args.output_dir) - with FileOutputCSV(summary_outfile) as out_fp: - tax_utils.write_summary(summarized_gather, out_fp, limit_float_decimals=limit_float) - - # if lineage summary table + # write summarized output in human-readable format if "lineage_summary" in args.output_format: lineage_outfile, limit_float = make_outfile(args.output_base, "lineage_summary", output_dir=args.output_dir) - ## aggregate by lineage, by query - lineageD, query_names, num_queries = tax_utils.aggregate_by_lineage_at_rank(summarized_gather[args.rank], by_query=True) + ## aggregate by lineage by query + lineageD, query_names= tax_utils.aggregate_by_lineage_at_rank(query_gather_results=query_gather_results, + rank=args.rank, by_query=True) with FileOutputCSV(lineage_outfile) as out_fp: - tax_utils.write_lineage_sample_frac(query_names, lineageD, out_fp, format_lineage=True, sep='\t') + tax_utils.write_lineage_sample_frac(query_names, lineageD, out_fp, sep='\t') # write summarized --> krona output tsv if "krona" in args.output_format: - krona_resultslist = tax_utils.format_for_krona(args.rank, summarized_gather) + krona_results, header = tax_utils.format_for_krona(query_gather_results, rank=args.rank) krona_outfile, limit_float = make_outfile(args.output_base, "krona", output_dir=args.output_dir) with FileOutputCSV(krona_outfile) as out_fp: - tax_utils.write_krona(args.rank, krona_resultslist, out_fp) + tax_utils.write_krona(header, krona_results, out_fp) + + if "human" in args.output_format: + summary_outfile, limit_float = make_outfile(args.output_base, "human", output_dir=args.output_dir) + + with FileOutput(summary_outfile) as out_fp: + human_display_rank = args.rank or "species" + if args.lins and not args.rank: + human_display_rank = query_gather_results[0].ranks[-1] # lowest rank + + tax_utils.write_human_summary(query_gather_results, out_fp, human_display_rank) + + # write summarized output csv + single_query_results = query_gather_results[0] + if "csv_summary" in args.output_format: + summary_outfile, limit_float = make_outfile(args.output_base, "csv_summary", output_dir=args.output_dir) + with FileOutputCSV(summary_outfile) as out_fp: + tax_utils.write_summary(query_gather_results, out_fp, limit_float_decimals=limit_float) + + # write summarized --> kreport output tsv + if "kreport" in args.output_format: + kreport_outfile, limit_float = make_outfile(args.output_base, "kreport", output_dir=args.output_dir) + + with FileOutputCSV(kreport_outfile) as out_fp: + header, kreport_results = single_query_results.make_kreport_results() + tax_utils.write_output(header, kreport_results, out_fp, sep="\t", write_header=False) + + # write summarized --> LINgroup output tsv + if "lingroup" in args.output_format: + try: + lingroups = tax_utils.read_lingroups(args.lingroup) + except ValueError as exc: + error(f"ERROR: {str(exc)}") + sys.exit(-1) + + lingroupfile, limit_float = make_outfile(args.output_base, "lingroup", output_dir=args.output_dir) + + with FileOutputCSV(lingroupfile) as out_fp: + header, lgreport_results = single_query_results.make_lingroup_results(LINgroupsD = lingroups) + tax_utils.write_output(header, lgreport_results, out_fp, sep="\t", write_header=True) + + # write cami bioboxes format + if "bioboxes" in args.output_format: + bbfile, limit_float = make_outfile(args.output_base, "bioboxes", output_dir=args.output_dir) + + with FileOutputCSV(bbfile) as out_fp: + header_lines, bb_results = single_query_results.make_cami_bioboxes() + tax_utils.write_bioboxes(header_lines, bb_results, out_fp, sep="\t") def genome(args): @@ -144,8 +202,15 @@ def genome(args): tax_assign = MultiLineageDB.load(args.taxonomy_csv, keep_full_identifiers=args.keep_full_identifiers, keep_identifier_versions=args.keep_identifier_versions, - force=args.force) + force=args.force, lins=args.lins) available_ranks = tax_assign.available_ranks + + lg_ranks=None + all_lgs=None + if args.lingroup: + lingroups = tax_utils.read_lingroups(args.lingroup) + lg_ranks, all_lgs = tax_utils.parse_lingroups(lingroups) + except ValueError as exc: error(f"ERROR: {str(exc)}") sys.exit(-1) @@ -161,96 +226,71 @@ def genome(args): # get gather_csvs from args gather_csvs = tax_utils.collect_gather_csvs(args.gather_csv, from_file=args.from_file) - classifications = defaultdict(list) - matched_queries=set() - krona_results = [] - status = "nomatch" - seen_perfect = set() - - # read in all gather CSVs (queries in more than one gather file will raise error; with --force they will only be loaded once) - # note: doing one CSV at a time would work and probably be more memory efficient, but we would need to change how we check - # for duplicated queries try: - gather_results, idents_missed, total_missed, _ = tax_utils.check_and_load_gather_csvs(gather_csvs, tax_assign, force=args.force, - fail_on_missing_taxonomy=args.fail_on_missing_taxonomy) + query_gather_results = tax_utils.check_and_load_gather_csvs(gather_csvs, tax_assign, force=args.force, + fail_on_missing_taxonomy=args.fail_on_missing_taxonomy, + keep_full_identifiers=args.keep_full_identifiers, + keep_identifier_versions = args.keep_identifier_versions, + lins=args.lins) except ValueError as exc: error(f"ERROR: {str(exc)}") sys.exit(-1) - # if --rank is specified, classify to that rank - if args.rank: + if not query_gather_results: + notify('No results for classification. Exiting.') + sys.exit(-1) + + # for each queryResult, summarize at rank and classify according to thresholds, reporting any errors that occur. + for queryResult in query_gather_results: try: - best_at_rank, seen_perfect = tax_utils.summarize_gather_at(args.rank, tax_assign, gather_results, skip_idents=idents_missed, - keep_full_identifiers=args.keep_full_identifiers, - keep_identifier_versions = args.keep_identifier_versions, - best_only=True, seen_perfect=seen_perfect) + queryResult.build_classification_result(rank=args.rank, + ani_threshold=args.ani_threshold, + containment_threshold=args.containment_threshold, + lingroup_ranks=lg_ranks, lingroups=all_lgs) + except ValueError as exc: error(f"ERROR: {str(exc)}") sys.exit(-1) - # best at rank is a list of SummarizedGather tuples - for sg in best_at_rank: - status = 'nomatch' - if sg.query_name in matched_queries: - continue - if sg.fraction <= args.containment_threshold: - status="below_threshold" - notify(f"WARNING: classifying query {sg.query_name} at desired rank {args.rank} does not meet containment threshold {args.containment_threshold}") - else: - status="match" - classif = ClassificationResult(sg.query_name, status, sg.rank, sg.fraction, sg.lineage, sg.query_md5, sg.query_filename, sg.f_weighted_at_rank, sg.bp_match_at_rank) - classifications[args.rank].append(classif) - matched_queries.add(sg.query_name) - if "krona" in args.output_format: - lin_list = display_lineage(sg.lineage).split(';') - krona_results.append((sg.fraction, *lin_list)) - else: - # classify to the match that passes the containment threshold. - # To do - do we want to store anything for this match if nothing >= containment threshold? - for rank in tax_utils.ascending_taxlist(include_strain=False): - # gets best_at_rank for all queries in this gather_csv - try: - best_at_rank, seen_perfect = tax_utils.summarize_gather_at(rank, tax_assign, gather_results, skip_idents=idents_missed, - keep_full_identifiers=args.keep_full_identifiers, - keep_identifier_versions = args.keep_identifier_versions, - best_only=True, seen_perfect=seen_perfect) - except ValueError as exc: - error(f"ERROR: {str(exc)}") - sys.exit(-1) - - for sg in best_at_rank: - status = 'nomatch' - if sg.query_name in matched_queries: - continue - if sg.fraction >= args.containment_threshold: - status = "match" - classif = ClassificationResult(sg.query_name, status, sg.rank, sg.fraction, sg.lineage, sg.query_md5, sg.query_filename, sg.f_weighted_at_rank, sg.bp_match_at_rank) - classifications[sg.rank].append(classif) - matched_queries.add(sg.query_name) - continue - if rank == "superkingdom" and status == "nomatch": - status="below_threshold" - classif = ClassificationResult(query_name=sg.query_name, status=status, - rank="", fraction=0, lineage="", - query_md5=sg.query_md5, query_filename=sg.query_filename, - f_weighted_at_rank=sg.f_weighted_at_rank, bp_match_at_rank=sg.bp_match_at_rank) - classifications[sg.rank].append(classif) - - if not any([classifications, krona_results]): - notify('No results for classification. Exiting.') - sys.exit(-1) - # write outputs if "csv_summary" in args.output_format: summary_outfile, limit_float = make_outfile(args.output_base, "classification", output_dir=args.output_dir) with FileOutputCSV(summary_outfile) as out_fp: - tax_utils.write_classifications(classifications, out_fp, limit_float_decimals=limit_float) + tax_utils.write_summary(query_gather_results, out_fp, limit_float_decimals=limit_float, classification=True) + + # write summarized output in human-readable format + if "human" in args.output_format: + summary_outfile, limit_float = make_outfile(args.output_base, "human", output_dir=args.output_dir) + with FileOutput(summary_outfile) as out_fp: + tax_utils.write_human_summary(query_gather_results, out_fp, args.rank or "species", classification=True) + + # The following require a single rank: + # note: interactive krona can handle mult ranks, do we want to enable? if "krona" in args.output_format: + krona_results, header = tax_utils.format_for_krona(query_gather_results=query_gather_results, rank=args.rank, classification=True) krona_outfile, limit_float = make_outfile(args.output_base, "krona", output_dir=args.output_dir) with FileOutputCSV(krona_outfile) as out_fp: - tax_utils.write_krona(args.rank, krona_results, out_fp) + tax_utils.write_krona(header, krona_results, out_fp) + + if "lineage_csv" in args.output_format: + lineage_outfile, _ = make_outfile(args.output_base, "lineage_csv", + output_dir=args.output_dir) + lineage_results = [] + header = None + for q_res in query_gather_results: + if not header: + ranks = list(q_res.ranks) + if 'strain' in ranks: # maintains prior functionality.. but we could keep strain now, i think? + ranks.remove('strain') + header = ["ident", *ranks] + lineageD = q_res.classification_result.as_lineage_dict(q_res.query_info, ranks) + lineage_results.append(lineageD) + with FileOutputCSV(lineage_outfile) as out_fp: + tax_utils.write_output(header, lineage_results, out_fp) + + def annotate(args): @@ -262,12 +302,13 @@ def annotate(args): set_quiet(args.quiet) - # first, load taxonomic_assignments try: + # first, load taxonomic_assignments tax_assign = MultiLineageDB.load(args.taxonomy_csv, keep_full_identifiers=args.keep_full_identifiers, keep_identifier_versions=args.keep_identifier_versions, - force=args.force) + force=args.force, lins=args.lins) + except ValueError as exc: error(f"ERROR: {str(exc)}") sys.exit(-1) @@ -276,33 +317,68 @@ def annotate(args): error(f'ERROR: No taxonomic assignments loaded from {",".join(args.taxonomy_csv)}. Exiting.') sys.exit(-1) - # get gather_csvs from args - gather_csvs = tax_utils.collect_gather_csvs(args.gather_csv, from_file=args.from_file) + # get csv from args + input_csvs = tax_utils.collect_gather_csvs(args.gather_csv, from_file=args.from_file) # handle each gather csv separately - for n, g_csv in enumerate(gather_csvs): - gather_results, idents_missed, total_missed, header = tax_utils.check_and_load_gather_csvs(g_csv, tax_assign, force=args.force, - fail_on_missing_taxonomy=args.fail_on_missing_taxonomy) - - if not gather_results: - continue - - out_base = os.path.basename(g_csv.rsplit('.csv')[0]) - this_outfile, limit_float = make_outfile(out_base, "annotate", output_dir=args.output_dir) - - with FileOutputCSV(this_outfile) as out_fp: - header.append("lineage") - w = csv.DictWriter(out_fp, header, delimiter=',') - w.writeheader() + for n, in_csv in enumerate(input_csvs): + try: + # Check for a column we can use to find lineage information: + with FileInputCSV(in_csv) as r: + header = r.fieldnames + # check for empty file + if not header: + raise ValueError(f"Cannot read from '{in_csv}'. Is file empty?") + + # look for the column to match with taxonomic identifier + id_col = None + col_options = ['name', 'match_name', 'ident', 'accession'] + for colname in col_options: + if colname in header: + id_col = colname + break + + if not id_col: + raise ValueError(f"Cannot find taxonomic identifier column in '{in_csv}'. Tried: {', '.join(col_options)}") + + notify(f"Starting annotation on '{in_csv}'. Using ID column: '{id_col}'") + + # make output file for this input + out_base = os.path.basename(in_csv.rsplit('.csv')[0]) + this_outfile, _ = make_outfile(out_base, "annotate", output_dir=args.output_dir) + + out_header = header + ['lineage'] + + with FileOutputCSV(this_outfile) as out_fp: + w = csv.DictWriter(out_fp, out_header) + w.writeheader() + + n = 0 + n_missed = 0 + for n, row in enumerate(r): + # find lineage and write annotated row + taxres = AnnotateTaxResult(raw=row, id_col=id_col, lins=args.lins, + keep_full_identifiers=args.keep_full_identifiers, + keep_identifier_versions=args.keep_identifier_versions) + taxres.get_match_lineage(tax_assignments=tax_assign, fail_on_missing_taxonomy=args.fail_on_missing_taxonomy) + + if taxres.missed_ident: # could not assign taxonomy + n_missed+=1 + w.writerow(taxres.row_with_lineages()) + + rows_annotated = (n+1) - n_missed + if not rows_annotated: + raise ValueError(f"Could not annotate any rows from '{in_csv}'.") + else: + notify(f"Annotated {rows_annotated} of {n+1} total rows from '{in_csv}'.") - # add taxonomy info and then print directly - for row in gather_results: - match_ident = row['name'] - lineage = tax_utils.find_match_lineage(match_ident, tax_assign, skip_idents=idents_missed, - keep_full_identifiers=args.keep_full_identifiers, - keep_identifier_versions=args.keep_identifier_versions) - row['lineage'] = display_lineage(lineage) - w.writerow(row) + except ValueError as exc: + if args.force: + notify(str(exc)) + notify('--force is set. Attempting to continue to next file.') + else: + error(f"ERROR: {str(exc)}") + sys.exit(-1) def prepare(args): @@ -310,6 +386,7 @@ def prepare(args): notify("loading taxonomies...") try: tax_assign = MultiLineageDB.load(args.taxonomy_csv, + force=args.force, keep_full_identifiers=args.keep_full_identifiers, keep_identifier_versions=args.keep_identifier_versions) except ValueError as exc: @@ -330,6 +407,122 @@ def prepare(args): notify("done!") +def grep(args): + term = args.pattern + tax_assign = MultiLineageDB.load(args.taxonomy_csv, + force=args.force) + + silent = args.silent or args.count + + notify(f"searching {len(args.taxonomy_csv)} taxonomy files for '{term}'") + if args.invert_match: + notify("-v/--invert-match specified; returning only lineages that do not match.") + if args.rank: + notify(f"limiting matches to {args.rank} level") + + # build the search pattern + pattern = args.pattern + if args.ignore_case: + pattern = re.compile(pattern, re.IGNORECASE) + else: + pattern = re.compile(pattern) + + # determine if lineage matches. + def find_pattern(lineage, select_rank): + for lp in lineage: + if select_rank is None or lp.rank == select_rank: + if pattern.search(lp.name): + return True + return False + + if args.invert_match: + def search_pattern(l, r): + return not find_pattern(l, r) + else: + search_pattern = find_pattern + + match_ident = [] + for ident, lineage in tax_assign.items(): + if search_pattern(lineage, args.rank): + match_ident.append((ident, lineage)) + + if silent: + notify(f"found {len(match_ident)} matches.") + notify("(no matches will be saved because of --silent/--count") + else: + with FileOutputCSV(args.output) as fp: + w = csv.writer(fp) + w.writerow(['ident'] + list(RankLineageInfo().taxlist[:-1])) + for ident, lineage in sorted(match_ident): + w.writerow([ident] + [ x.name for x in lineage ]) + + notify(f"found {len(match_ident)} matches; saved identifiers to picklist file '{args.output}'") + + +def summarize(args): + "Summarize multiple taxonomy databases." + notify("loading taxonomies...") + try: + tax_assign = MultiLineageDB.load(args.taxonomy_files, + force=args.force, + keep_full_identifiers=args.keep_full_identifiers, + keep_identifier_versions=args.keep_identifier_versions, + lins=args.lins) + except ValueError as exc: + error("ERROR while loading taxonomies!") + error(str(exc)) + sys.exit(-1) + + notify(f"...loaded {len(tax_assign)} entries.") + + print_results(f"number of distinct taxonomic lineages: {len(tax_assign)}") + + # count the number of distinct lineage names seen + rank_counts = defaultdict(int) + name_seen = set() + for v in tax_assign.values(): + sofar = [] + for vv in v: + name = vv.name + rank = vv.rank + if name not in name_seen: + rank_counts[rank] += 1 + name_seen.add(name) + + rank_count_items = list(rank_counts.items()) + rank_count_items.sort(key=lambda x: x[1]) + for rank, count in rank_count_items: + rank_name_str = f"{rank}:" + print_results(f"rank {rank_name_str:<20s} {count} distinct taxonomic lineages") + + if args.output_lineage_information: + notify("now calculating detailed lineage counts...") + lineage_counts = Counter() + for v in tax_assign.values(): + tup = v + while tup: + lineage_counts[tup] += 1 + tup = tup[:-1] + notify("...done!") + + with FileOutputCSV(args.output_lineage_information) as fp: + w = csv.writer(fp) + w.writerow(['rank', 'lineage_count', 'lineage']) + + # output in order of most common + for lineage, count in lineage_counts.most_common(): + rank = lineage[-1].rank + if args.lins: + inf = LINLineageInfo(lineage=lineage) + else: + inf = RankLineageInfo(lineage=lineage) + lin = inf.display_lineage() + w.writerow([rank, str(count), lin]) + + n = len(lineage_counts) + notify(f"saved {n} lineage counts to '{args.output_lineage_information}'") + + def main(arglist=None): args = sourmash.cli.get_parser().parse_args(arglist) submod = getattr(sourmash.cli.sig, args.subcmd) diff --git a/src/sourmash/tax/tax_utils.py b/src/sourmash/tax/tax_utils.py index d5a7161afc..4bd7ddd8d9 100644 --- a/src/sourmash/tax/tax_utils.py +++ b/src/sourmash/tax/tax_utils.py @@ -3,37 +3,567 @@ """ import os import csv -from collections import namedtuple, defaultdict -from collections import abc +from collections import abc, defaultdict +from itertools import zip_longest +from typing import NamedTuple +from dataclasses import dataclass, field, replace, asdict +import gzip -from sourmash import sqlite_utils +from sourmash import sqlite_utils, sourmash_args from sourmash.exceptions import IndexNotSupported +from sourmash.distance_utils import containment_to_distance import sqlite3 __all__ = ['get_ident', 'ascending_taxlist', 'collect_gather_csvs', - 'load_gather_results', 'check_and_load_gather_csvs', - 'find_match_lineage', 'summarize_gather_at', - 'find_missing_identities', 'make_krona_header', - 'aggregate_by_lineage_at_rank', 'format_for_krona', - 'write_krona', 'write_summary', 'write_classifications', + 'load_gather_results', 'check_and_load_gather_csvs' + 'report_missing_and_skipped_identities', 'aggregate_by_lineage_at_rank' + 'format_for_krona', 'write_output', 'write_bioboxes', 'parse_lingroups', 'combine_sumgather_csvs_by_lineage', 'write_lineage_sample_frac', - 'MultiLineageDB'] + 'MultiLineageDB', 'RankLineageInfo', 'LINLineageInfo'] from sourmash.logging import notify from sourmash.sourmash_args import load_pathlist_from_file -QueryInfo = namedtuple("QueryInfo", "query_md5, query_filename, query_bp") -SummarizedGatherResult = namedtuple("SummarizedGatherResult", "query_name, rank, fraction, lineage, query_md5, query_filename, f_weighted_at_rank, bp_match_at_rank") -ClassificationResult = namedtuple("ClassificationResult", "query_name, status, rank, fraction, lineage, query_md5, query_filename, f_weighted_at_rank, bp_match_at_rank") +RANKCODE = { "superkingdom": "D", "kingdom": "K", "phylum": "P", "class": "C", + "order": "O", "family":"F", "genus": "G", "species": "S", "unclassified": "U"} -# Essential Gather column names that must be in gather_csv to allow `tax` summarization -EssentialGatherColnames = ('query_name', 'name', 'f_unique_weighted', 'f_unique_to_query', 'unique_intersect_bp', 'remaining_bp', 'query_md5', 'query_filename') +class LineagePair(NamedTuple): + rank: str + name: str = None + taxid: int = None -# import lca utils as needed for now -from sourmash.lca import lca_utils -from sourmash.lca.lca_utils import (LineagePair, taxlist, display_lineage, pop_to_rank) +@dataclass(frozen=True, order=True) +class BaseLineageInfo: + """ + This BaseLineageInfo class defines a set of methods that can be used to handle + summarization and manipulation of taxonomic lineages with hierarchical taxonomic ranks. + + Inputs: + required: + ranks: tuple or list of hierarchical ranks + optional: + lineage: tuple or list of LineagePair + lineage_str: `;`- or `,`-separated string of names + + If no lineage information is provided, result will be a BaseLineageInfo + with provided ranks and no lineage names. + + Input lineage information is only used for initialization of the final `lineage` + and will not be used or compared in any other class methods. + """ + # need to set compare=False for any mutable type to keep this class hashable + ranks: tuple() # require ranks + lineage: tuple = None # tuple of LineagePairs + lineage_str: str = field(default=None, compare=False) # ';'- or ','-separated str of lineage names + + def __post_init__(self): + "Initialize according to passed values" + # ranks must be tuple for hashability + if isinstance(self.ranks, list): + object.__setattr__(self, "ranks", tuple(self.ranks)) + if self.lineage is not None: + self._init_from_lineage_tuples() + elif self.lineage_str is not None: + self._init_from_lineage_str() + else: + self._init_empty() + + def __eq__(self, other): + if other == (): # just handy: if comparing to a null tuple, don't try to find its lineage before returning False + return False + return all([self.ranks == other.ranks and self.lineage==other.lineage]) + + @property + def taxlist(self): + return self.ranks + + @property + def ascending_taxlist(self): + return self.ranks[::-1] + + @property + def lowest_rank(self): + if not self.filled_ranks: + return None + return self.filled_ranks[-1] + + def rank_index(self, rank): + self.check_rank_availability(rank) + return self.ranks.index(rank) + + def name_at_rank(self, rank): + "Return the lineage name at this rank" + self.check_rank_availability(rank) + if not self.filled_ranks or rank not in self.filled_ranks: + return None + rank_idx = self.rank_index(rank) + return self.filled_lineage[rank_idx].name + + @property + def filled_lineage(self): + """Return lineage down to lowest non-empty rank. Preserves missing ranks above.""" + # Would we prefer this to be the default returned by lineage?? + if not self.filled_ranks: + return () + lowest_filled_rank_idx = self.rank_index(self.filled_ranks[-1]) + return self.lineage[:lowest_filled_rank_idx+1] + + @property + def lowest_lineage_name(self): + "Return the name of the lowest filled lineage" + if not self.filled_ranks: + return None + return self.filled_lineage[-1].name + + @property + def lowest_lineage_taxid(self): + "Return the taxid of the lowest filled lineage" + if not self.filled_ranks: + return None + return self.filled_lineage[-1].taxid + + def _init_empty(self): + 'initialize empty genome lineage' + new_lineage = [] + for rank in self.ranks: + new_lineage.append(LineagePair(rank=rank)) + # set lineage and filled_ranks (because frozen, need to do it this way) + object.__setattr__(self, "lineage", tuple(new_lineage)) + object.__setattr__(self, "filled_ranks", ()) + + def _init_from_lineage_tuples(self): + 'initialize from tuple/list of LineagePairs, allowing empty ranks and reordering if necessary' + new_lineage = [] + # check this is a list or tuple of lineage tuples: + for rank in self.ranks: + new_lineage.append(LineagePair(rank=rank)) + for lin_tup in self.lineage: + # now add input tuples in correct spots. This corrects for order and allows empty values. + if not isinstance(lin_tup, LineagePair): + raise ValueError(f"{lin_tup} is not tax_utils LineagePair.") + if lin_tup.rank: # skip this tuple if rank is None or "" (empty lineage tuple. is this needed?) + try: + # find index for this rank + rank_idx = self.rank_index(lin_tup.rank) + except ValueError as e: + raise ValueError(f"Rank '{lin_tup.rank}' not present in {', '.join(self.ranks)}") from e + new_lineage[rank_idx] = lin_tup + + # build list of filled ranks + filled_ranks = [a.rank for a in new_lineage if a.name is not None] + # set lineage and filled_ranks + object.__setattr__(self, "lineage", tuple(new_lineage)) + object.__setattr__(self, "filled_ranks", tuple(filled_ranks)) + + def _init_from_lineage_str(self): + """ + Turn a ; or ,-separated set of lineages into a list of LineagePair objs. + """ + new_lineage = self.lineage_str.split(';') + if len(new_lineage) == 1: + new_lineage = self.lineage_str.split(',') + new_lineage = [ LineagePair(rank=rank, name=n) for (rank, n) in zip_longest(self.ranks, new_lineage) ] + # build list of filled ranks + filled_ranks = [a.rank for a in new_lineage if a.name is not None] + object.__setattr__(self, "lineage", tuple(new_lineage)) + object.__setattr__(self, "filled_ranks", tuple(filled_ranks)) + + def zip_lineage(self, truncate_empty=False): + """ + Return lineage names as a list + """ + if truncate_empty: + zipped = [a.name for a in self.filled_lineage] + else: + zipped = [a.name for a in self.lineage] + # replace None with empty string ("") + if None in zipped: + zipped = ['' if x is None else x for x in zipped] + + return zipped + + def zip_taxid(self, truncate_empty=False): + """ + Return taxids as a list + """ + if truncate_empty: + zipped = [a.taxid for a in self.filled_lineage] + else: + zipped = [a.taxid for a in self.lineage] + # replace None with empty string (""); cast taxids to str + zipped = ['' if x is None else str(x) for x in zipped] + + return zipped + + def display_lineage(self, truncate_empty=True, null_as_unclassified=False, sep = ';'): + "Return lineage names as ';'-separated list" + lin = sep.join(self.zip_lineage(truncate_empty=truncate_empty)) + if null_as_unclassified and lin == "" or lin is None: + return "unclassified" + else: + return lin + + def display_taxid(self, truncate_empty=True, sep = ";"): + "Return lineage taxids as ';'-separated list" + return sep.join(self.zip_taxid(truncate_empty=truncate_empty)) + + def check_rank_availability(self, rank): + if rank in self.ranks: # rank is available + return True + raise ValueError(f"Desired Rank '{rank}' not available for this lineage.") + + def rank_is_filled(self, rank, other=None): + self.check_rank_availability(rank) + if other is not None: + if rank in self.filled_ranks and rank in other.filled_ranks: + return True + elif rank in self.filled_ranks: + return True + return False + + def is_compatible(self, other): + if self.ranks == other.ranks: + return True + return False + + def is_lineage_match(self, other, rank): + """ + check to see if two lineages are a match down to given rank. + """ + self.check_rank_availability(rank) + if not self.is_compatible(other): + raise ValueError("Cannot compare lineages from taxonomies with different ranks.") + # always return false if rank is not filled in either of the two lineages + if self.rank_is_filled(rank, other=other): + rank_idx = self.rank_index(rank) + a_lin = self.lineage[:rank_idx+1] + b_lin = other.lineage[:rank_idx+1] + if a_lin == b_lin: + return 1 + return 0 + + def pop_to_rank(self, rank): + "Return new LineageInfo with ranks only filled to desired rank" + # are we already above rank? + self.check_rank_availability(rank) + if not self.rank_is_filled(rank): + return replace(self) + # if not, make filled_lineage at this rank + use to generate new LineageInfo + new_lineage = self.lineage_at_rank(rank) + new = replace(self, lineage = new_lineage) + # replace doesn't run the __post_init__ properly. reinitialize. + new._init_from_lineage_tuples() + return new + + def lineage_at_rank(self, rank): + "Return tuple of LineagePairs at specified rank." + # are we already above rank? + self.check_rank_availability(rank) + if not self.rank_is_filled(rank): + return self.filled_lineage + # if not, return lineage tuples down to desired rank + rank_idx = self.rank_index(rank) + return self.filled_lineage[:rank_idx+1] + + def find_lca(self, other): + """ + If an LCA match exists between self and other, + find and report LCA lineage. If not, return None. + """ + for rank in self.ascending_taxlist: + if self.is_lineage_match(other, rank): + return self.pop_to_rank(rank) + return None + + +@dataclass(frozen=True, order=True) +class RankLineageInfo(BaseLineageInfo): + """ + This RankLineageInfo class usees the BaseLineageInfo methods for a standard set + of taxonomic ranks. + + Inputs: + optional: + ranks: tuple or list of hierarchical ranks + default: ('superkingdom', 'phylum', 'class', 'order', 'family', 'genus', 'species', 'strain') + lineage: tuple or list of LineagePair + lineage_str: `;`- or `,`-separated string of names + lineage_dict: dictionary of {rank: name} + + If no inputs are provided, result will be RankLineageInfo with + default ranks and no lineage names. + + Input lineage information is only used for initialization of the final `lineage` + and will not be used or compared in any other class methods. + """ + ranks: tuple = ('superkingdom', 'phylum', 'class', 'order', 'family', 'genus', 'species', 'strain') + lineage_dict: dict = field(default=None, compare=False) # dict of rank: name + + def __post_init__(self): + "Initialize according to passed values" + # ranks must be tuple for hashability + if isinstance(self.ranks, list): + object.__setattr__(self, "ranks", tuple(self.ranks)) + if self.lineage is not None: + self._init_from_lineage_tuples() + elif self.lineage_str is not None: + self._init_from_lineage_str() + elif self.lineage_dict is not None: + self._init_from_lineage_dict() + elif self.ranks: + self._init_empty() + + def _init_from_lineage_dict(self): + """ + Initialize from lineage dict, e.g. from lineages csv. + Use NCBI taxids if available as '|'-separated 'taxpath' column. + Allows empty ranks/extra columns and reordering if necessary + """ + null_names = set(['[Blank]', 'na', 'null', 'NA', '']) + if not isinstance(self.lineage_dict, (dict)): + raise ValueError(f"{self.lineage_dict} is not dictionary") + new_lineage = [] + taxpath=[] + # build empty lineage and taxpath + for rank in self.ranks: + new_lineage.append(LineagePair(rank=rank)) + + # check for NCBI taxpath information + taxpath_str = self.lineage_dict.get('taxpath', []) + if taxpath_str: + taxpath = taxpath_str.split('|') + if len(taxpath) > len(self.ranks): + raise ValueError(f"Number of NCBI taxids ({len(taxpath)}) exceeds number of ranks ({len(self.ranks)})") + + # now add rank information in correct spots. This corrects for order and allows empty ranks and extra dict keys + for key, val in self.lineage_dict.items(): + name, taxid = None, None + try: + rank, name = key, val + rank_idx = self.rank_index(rank) + except ValueError: + continue # ignore dictionary entries (columns) that don't match a rank + + if taxpath: + try: + taxid = taxpath[rank_idx] + except IndexError: + taxid = None + # filter null + if name is not None and name.strip() in null_names: + name = None + new_lineage[rank_idx] = LineagePair(rank=rank, name=name, taxid=taxid) + + # build list of filled ranks + filled_ranks = [a.rank for a in new_lineage if a.name] + # set lineage and filled_ranks + object.__setattr__(self, "lineage", tuple(new_lineage)) + object.__setattr__(self, "filled_ranks", tuple(filled_ranks)) + + +@dataclass(frozen=True, order=True) +class LINLineageInfo(BaseLineageInfo): + """ + This LINLineageInfo class uses the BaseLineageInfo methods for hierarchical LIN taxonomic 'ranks'. + + Inputs (at least one required): + n_lin_positions: the number of lineage positions + lineage_str: `;`- or `,`-separated LINS string + + If both `n_lin_positions` and `lineage_str` are provided, we will initialize a `LINLineageInfo` + with the provided n_lin_positions, and fill positions with `lineage_str` values. If the number of + positions is less than provided lineages, initialization will fail. Otherwise, we will insert blanks + beyond provided data in `lineage_str`. + + If no information is passed, an empty LINLineageInfo will be initialized (n_lin_positions=0). + + Input lineage information is only used for initialization of the final `lineage` + and will not be used or compared in any other class methods. + """ + ranks: tuple = field(default=None, init=False, compare=False)# we will set this within class instead + lineage: tuple = None + # init with n_positions if you want to set a specific number of positions + n_lin_positions: int = field(default=None, compare=False) + + def __post_init__(self): + "Initialize according to passed values" + # ranks must be tuple for hashability + if self.lineage is not None: + self._init_from_lineage_tuples() + elif self.lineage_str is not None: + self._init_from_lineage_str() + else: + self._init_empty() + + def __eq__(self, other): + """ + Check if two LINLineageInfo match. Since we sometimes want to match LINprefixes, which have fewer + total ranks, with full LINs, we only check for the filled_lineage to match and don't check that + the number of lin_positions match. + """ + if other == (): # if comparing to a null tuple, don't try to find its lineage before returning False + return False + return self.filled_lineage==other.filled_lineage + + def _init_ranks_from_n_lin_positions(self): + new_ranks = [str(x) for x in range(0, self.n_lin_positions)] + object.__setattr__(self, "ranks", new_ranks) + + def _init_empty(self): + "initialize empty genome lineage" + # first, set ranks from n_positions + if self.n_lin_positions is None: + # set n_lin_positions to 0 for completely empty LINLineageInfo + object.__setattr__(self, "n_lin_positions", 0) + self._init_ranks_from_n_lin_positions() + new_lineage=[] + for rank in self.ranks: + new_lineage.append(LineagePair(rank=rank)) + # set lineage and filled_ranks (because frozen, need to do it this way) + object.__setattr__(self, "lineage", tuple(new_lineage)) + object.__setattr__(self, "filled_ranks", ()) + object.__setattr__(self, "n_filled_pos", 0) + + def _init_from_lineage_str(self): + """ + Turn a ; or ,-separated set of lineages into a list of LineagePair objs. + """ + new_lineage = self.lineage_str.split(';') + if len(new_lineage) == 1: + new_lineage = self.lineage_str.split(',') + if self.n_lin_positions is not None: + if self.n_lin_positions < len(new_lineage): + raise(ValueError("Provided 'n_lin_positions' has fewer positions than provided 'lineage_str'.")) + self._init_ranks_from_n_lin_positions() + else: + n_lin_positions = len(new_lineage) + object.__setattr__(self, "n_lin_positions", n_lin_positions) + self._init_ranks_from_n_lin_positions() + + # build lineage and n_filled_pos, filled_ranks + new_lineage = [ LineagePair(rank=rank, name=n) for (rank, n) in zip_longest(self.ranks, new_lineage) ] + filled_ranks = [a.rank for a in new_lineage if a.name is not None] + object.__setattr__(self, "lineage", tuple(new_lineage)) + object.__setattr__(self, "filled_ranks", tuple(filled_ranks)) + object.__setattr__(self, "n_filled_pos", len(filled_ranks)) + + def _init_from_lineage_tuples(self): + 'initialize from tuple/list of LineagePairs, building ranks as you go' + new_lineage = [] + ranks = [] + # check this is a list or tuple of lineage tuples: + for lin_tup in self.lineage: + # make sure we're adding tax_utils.LineagePairs + if not isinstance(lin_tup, LineagePair): + raise ValueError(f"{lin_tup} is not tax_utils LineagePair.") + new_lineage.append(lin_tup) + ranks.append(lin_tup.rank) + # build list of filled ranks + filled_ranks = [a.rank for a in new_lineage if a.name is not None] + # set lineage and filled_ranks + object.__setattr__(self, "lineage", tuple(new_lineage)) + object.__setattr__(self, "n_lin_positions", len(new_lineage)) + object.__setattr__(self, "ranks", tuple(ranks)) + object.__setattr__(self, "filled_ranks", tuple(filled_ranks)) + object.__setattr__(self, "n_filled_pos", len(filled_ranks)) + + + def is_compatible(self, other): + """ + Since we sometimes want to match LINprefixes with full LINs, + we don't want to enforce identical ranks. Here we just look to + make sure self and other share any ranks (LIN positions). + + Since ranks are positions, this should be true for LINLineageInfo + unless one is empty. However, it should prevent comparison between + other LineageInfo instances and LINLineageInfo. + """ + # do self and other share any ranks? + if any(x in self.ranks for x in other.ranks): + return True + return False + + + +@dataclass +class LineageTree: + """ + Builds a tree of dictionaries from lists of LineagePair or + LineageInfo objects in 'assignments'. This tree can then be used + to find lowest common ancestor agreements/confusion. + """ + assignments: list = field(compare=False) + + def __post_init__(self): + self.tree = {} + self.add_lineages(self.assignments) + + def add_lineage(self, lineage): + if isinstance(lineage, (BaseLineageInfo, RankLineageInfo, LINLineageInfo)): + lineage = lineage.filled_lineage + node = self.tree + for lineage_tup in lineage: + if lineage_tup.name: + child = node.get(lineage_tup, {}) + node[lineage_tup] = child + # shift -> down in tree + node = child + + def add_lineages(self, lineages): + if not lineages: + raise ValueError("empty assignment passed to build_tree") + if not isinstance(lineages, abc.Iterable): + raise ValueError("Must pass in an iterable containing LineagePair or LineageInfo objects.") + for lineageInf in lineages: + self.add_lineage(lineageInf) + + def find_lca(self): + """ + Given a LineageTree tree, find the first node with multiple + children, OR the only leaf in the tree. Return (lineage_tup, reason), + where 'reason' is the number of children of the returned node, i.e. + 0 if it's a leaf and > 1 if it's an internal node. + """ + node = self.tree + lca = [] + while 1: + if len(node) == 1: # descend to only child; track path + lineage_tup = next(iter(node.keys())) + lca.append(lineage_tup) + node = node[lineage_tup] + elif len(node) == 0: # at leaf; end + return tuple(lca), 0 + else: # len(node) > 1 => confusion!! + return tuple(lca), len(node) + + def ordered_paths(self, include_internal=False): + """ + Find all paths in the nested dict in a depth-first manner. + Each path is a tuple of lineage tuples that lead from the root + to a leaf node. Optionally include internal nodes by building + them up from leaf nodes (for ordering). + """ + paths = [] + stack = [((), self.tree)] + while stack: + path, node = stack.pop() + for key, val in node.items(): + if len(val) == 0: # leaf node + # if want internal paths, build up from leaf + if include_internal: + internal_path = path + while internal_path: + if internal_path not in paths: + paths.append(internal_path) + if isinstance(internal_path, abc.Iterable): + internal_path = internal_path[:-1] + # now add leaf path + paths.append(path + (key,)) + else: # not leaf, add to stack + stack.append((path + (key,), val)) + return paths def get_ident(ident, *, @@ -82,68 +612,113 @@ def collect_gather_csvs(cmdline_gather_input, *, from_file=None): return gather_csvs -def load_gather_results(gather_csv, *, delimiter=',', essential_colnames=EssentialGatherColnames, seen_queries=None, force=False): +def read_lingroups(lingroup_csv): + lingroupD = {} + n=None + with sourmash_args.FileInputCSV(lingroup_csv) as r: + header = r.fieldnames + # check for empty file + if not header: + raise ValueError(f"Cannot read lingroups from '{lingroup_csv}'. Is file empty?") + if "lin" not in header or "name" not in header: + raise ValueError(f"'{lingroup_csv}' must contain the following columns: 'name', 'lin'.") + for n, row in enumerate(r): + lingroupD[row['lin']] = row['name'] + + if n is None: + raise ValueError(f'No lingroups loaded from {lingroup_csv}.') + n_lg = len(lingroupD.keys()) + notify(f"Read {n+1} lingroup rows and found {n_lg} distinct lingroup prefixes.") + return lingroupD + + +def parse_lingroups(lingroupD): + # find the ranks we need to consider + all_lgs = set() + lg_ranks = set() + for lg_prefix in lingroupD.keys(): + # store lineage info for LCA pathfinding + lg_info = LINLineageInfo(lineage_str=lg_prefix) + all_lgs.add(lg_info) + # store rank so we only go through summarized results at these ranks + lg_rank = str(lg_info.lowest_rank) + lg_ranks.add(lg_rank) + return lg_ranks, all_lgs + + +def load_gather_results(gather_csv, tax_assignments, *, seen_queries=None, force=False, + skip_idents = None, fail_on_missing_taxonomy=False, + keep_full_identifiers=False, keep_identifier_versions=False, + lins=False): "Load a single gather csv" if not seen_queries: seen_queries=set() header = [] - gather_results = [] - gather_queries = set() - with open(gather_csv, 'rt') as fp: - r = csv.DictReader(fp, delimiter=delimiter) + gather_results = {} + with sourmash_args.FileInputCSV(gather_csv) as r: header = r.fieldnames # check for empty file if not header: - raise ValueError(f'Cannot read gather results from {gather_csv}. Is file empty?') - - #check for critical column names used by summarize_gather_at - if not set(essential_colnames).issubset(header): - raise ValueError(f'Not all required gather columns are present in {gather_csv}.') + raise ValueError(f"Cannot read gather results from '{gather_csv}'. Is file empty?") + this_querytaxres = None for n, row in enumerate(r): - query_name = row['query_name'] + # try reading each gather row into a TaxResult + try: + gatherRow = GatherRow(**row) + except TypeError as exc: + raise ValueError(f"'{gather_csv}' is missing columns needed for taxonomic summarization. Please run gather with sourmash >= 4.4.") from exc # check if we've seen this query already in a different gather CSV - if query_name in seen_queries: - if query_name not in gather_queries: #seen already in this CSV? (only want to warn once per query per CSV) - notify(f"WARNING: Gather query {query_name} was already loaded from a separate gather CSV. Cannot load duplicate query from CSV {gather_csv}...") - if force: - if query_name not in gather_queries: - notify("--force is set, ignoring duplicate query.") - gather_queries.add(query_name) - continue - else: - raise ValueError(f"Gather query {query_name} was found in more than one CSV. Cannot load from {gather_csv}.") - else: - gather_results.append(row) - # add query name to the gather_queries from this CSV - if query_name not in gather_queries: - gather_queries.add(query_name) + if gatherRow.query_name in seen_queries: + # do not allow loading of same query from a second CSV. + raise ValueError(f"Gather query {gatherRow.query_name} was found in more than one CSV. Cannot load from '{gather_csv}'.") + taxres = TaxResult(raw=gatherRow, keep_full_identifiers=keep_full_identifiers, + keep_identifier_versions=keep_identifier_versions, + lins=lins) + taxres.get_match_lineage(tax_assignments=tax_assignments, skip_idents=skip_idents, + fail_on_missing_taxonomy=fail_on_missing_taxonomy) + # add to matching QueryTaxResult or create new one + if not this_querytaxres or not this_querytaxres.is_compatible(taxres): + # get existing or initialize new + this_querytaxres = gather_results.get(gatherRow.query_name, QueryTaxResult(taxres.query_info, lins=lins)) + this_querytaxres.add_taxresult(taxres) + gather_results[gatherRow.query_name] = this_querytaxres if not gather_results: raise ValueError(f'No gather results loaded from {gather_csv}.') else: - notify(f'loaded {len(gather_results)} gather results.') - return gather_results, header, gather_queries + notify(f"loaded {len(gather_results)} gather results from '{gather_csv}'.") + return gather_results, header #, gather_queries # can use the gather_results keys instead -def check_and_load_gather_csvs(gather_csvs, tax_assign, *, fail_on_missing_taxonomy=False, force=False): +def check_and_load_gather_csvs(gather_csvs, tax_assign, *, fail_on_missing_taxonomy=False, force=False, + keep_full_identifiers=False,keep_identifier_versions=False, lins=False): ''' Load gather csvs, checking for empties and ids missing from taxonomic assignments. ''' if not isinstance(gather_csvs, list): gather_csvs = [gather_csvs] - gather_results = [] + gather_results = {} total_missed = 0 all_ident_missed = set() - seen_queries = set() header = [] n_ignored = 0 for n, gather_csv in enumerate(gather_csvs): - these_results = [] + these_results = {} try: - these_results, header, seen_queries = load_gather_results(gather_csv, seen_queries=seen_queries, force=force) + these_results, header = load_gather_results(gather_csv, tax_assign, + seen_queries=gather_results.keys(), + force=force, keep_full_identifiers=keep_full_identifiers, + keep_identifier_versions = keep_identifier_versions, + fail_on_missing_taxonomy=fail_on_missing_taxonomy, + lins=lins) except ValueError as exc: if force: + if "found in more than one CSV" in str(exc): + notify('Cannot force past duplicated gather query. Exiting.') + raise + if "Failing, as requested via --fail-on-missing-taxonomy" in str(exc): + raise notify(str(exc)) notify('--force is set. Attempting to continue to next set of gather results.') n_ignored+=1 @@ -152,317 +727,202 @@ def check_and_load_gather_csvs(gather_csvs, tax_assign, *, fail_on_missing_taxon notify('Exiting.') raise - # check for match identites in these gather_results not found in lineage spreadsheets - n_missed, ident_missed = find_missing_identities(these_results, tax_assign) - if n_missed: - notify(f'The following are missing from the taxonomy information: {",".join(ident_missed)}') - if fail_on_missing_taxonomy: - raise ValueError('Failing on missing taxonomy, as requested via --fail-on-missing-taxonomy.') - - total_missed += n_missed - all_ident_missed.update(ident_missed) # add these results to gather_results - gather_results += these_results - + gather_results.update(these_results) + + # some reporting num_gather_csvs_loaded = n+1 - n_ignored - notify(f'loaded results from {str(num_gather_csvs_loaded)} gather CSVs') - - return gather_results, all_ident_missed, total_missed, header - - -def find_match_lineage(match_ident, tax_assign, *, skip_idents = [], - keep_full_identifiers=False, - keep_identifier_versions=False): - lineage="" - match_ident = get_ident(match_ident, keep_full_identifiers=keep_full_identifiers, keep_identifier_versions=keep_identifier_versions) - # if identity not in lineage database, and not --fail-on-missing-taxonomy, skip summarizing this match - if match_ident in skip_idents: - return lineage - try: - lineage = tax_assign[match_ident] - except KeyError: - raise ValueError(f"ident {match_ident} is not in the taxonomy database.") - return lineage - - -def summarize_gather_at(rank, tax_assign, gather_results, *, skip_idents = [], - keep_full_identifiers=False, - keep_identifier_versions=False, best_only=False, - seen_perfect=set()): - """ - Summarize gather results at specified taxonomic rank - """ - # init dictionaries - sum_uniq_weighted = defaultdict(lambda: defaultdict(float)) - # store together w/ ^ instead? - sum_uniq_to_query = defaultdict(lambda: defaultdict(float)) - sum_uniq_bp = defaultdict(lambda: defaultdict(float)) - query_info = {} - - for row in gather_results: - # get essential gather info - query_name = row['query_name'] - f_unique_to_query = float(row['f_unique_to_query']) - f_uniq_weighted = float(row['f_unique_weighted']) - unique_intersect_bp = int(row['unique_intersect_bp']) - query_md5 = row['query_md5'] - query_filename = row['query_filename'] - # get query_bp - if query_name not in query_info.keys(): - query_bp = unique_intersect_bp + int(row['remaining_bp']) - # store query info - query_info[query_name] = QueryInfo(query_md5=query_md5, query_filename=query_filename, query_bp=query_bp) - match_ident = row['name'] - - - # 100% match? are we looking at something in the database? - if f_unique_to_query >= 1.0 and query_name not in seen_perfect: # only want to notify once, not for each rank - ident = get_ident(match_ident, - keep_full_identifiers=keep_full_identifiers, - keep_identifier_versions=keep_identifier_versions) - seen_perfect.add(query_name) - notify(f'WARNING: 100% match! Is query "{query_name}" identical to its database match, {ident}?') - - # get lineage for match - lineage = find_match_lineage(match_ident, tax_assign, - skip_idents=skip_idents, - keep_full_identifiers=keep_full_identifiers, - keep_identifier_versions=keep_identifier_versions) - # ident was in skip_idents - if not lineage: - continue + notify(f'loaded results for {len(gather_results)} queries from {str(num_gather_csvs_loaded)} gather CSVs') + # count and report missing and skipped idents + report_missing_and_skipped_identities(gather_results) - # summarize at rank! - lineage = pop_to_rank(lineage, rank) - assert lineage[-1].rank == rank, (rank, lineage[-1]) - # record info - sum_uniq_to_query[query_name][lineage] += f_unique_to_query - sum_uniq_weighted[query_name][lineage] += f_uniq_weighted - sum_uniq_bp[query_name][lineage] += unique_intersect_bp - - # sort and store each as SummarizedGatherResult - sum_uniq_to_query_sorted = [] - for query_name, lineage_weights in sum_uniq_to_query.items(): - qInfo = query_info[query_name] - sumgather_items = list(lineage_weights.items()) - sumgather_items.sort(key = lambda x: -x[1]) - if best_only: - lineage, fraction = sumgather_items[0] - if fraction > 1: - raise ValueError(f"The tax summary of query '{query_name}' is {fraction}, which is > 100% of the query!! This should not be possible. Please check that your input files come directly from a single gather run per query.") - elif fraction == 0: - continue - f_weighted_at_rank = sum_uniq_weighted[query_name][lineage] - bp_intersect_at_rank = sum_uniq_bp[query_name][lineage] - sres = SummarizedGatherResult(query_name, rank, fraction, lineage, qInfo.query_md5, qInfo.query_filename, f_weighted_at_rank, bp_intersect_at_rank) - sum_uniq_to_query_sorted.append(sres) - else: - total_f_weighted= 0.0 - total_f_classified = 0.0 - total_bp_classified = 0 - for lineage, fraction in sumgather_items: - if fraction > 1: - raise ValueError(f"The tax summary of query '{query_name}' is {fraction}, which is > 100% of the query!! This should not be possible. Please check that your input files come directly from a single gather run per query.") - elif fraction == 0: - continue - total_f_classified += fraction - f_weighted_at_rank = sum_uniq_weighted[query_name][lineage] - total_f_weighted += f_weighted_at_rank - bp_intersect_at_rank = int(sum_uniq_bp[query_name][lineage]) - total_bp_classified += bp_intersect_at_rank - sres = SummarizedGatherResult(query_name, rank, fraction, lineage, query_md5, query_filename, f_weighted_at_rank, bp_intersect_at_rank) - sum_uniq_to_query_sorted.append(sres) - - # record unclassified - lineage = () - fraction = 1.0 - total_f_classified - if fraction > 0: - f_weighted_at_rank = 1.0 - total_f_weighted - bp_intersect_at_rank = qInfo.query_bp - total_bp_classified - sres = SummarizedGatherResult(query_name, rank, fraction, lineage, query_md5, query_filename, f_weighted_at_rank, bp_intersect_at_rank) - sum_uniq_to_query_sorted.append(sres) + # just return the list of QueryTaxResults + query_results_list = list(gather_results.values()) - return sum_uniq_to_query_sorted, seen_perfect + return query_results_list -def find_missing_identities(gather_results, tax_assign): +def report_missing_and_skipped_identities(gather_results): """ - Identify match ids/accessions from gather results - that are not present in taxonomic assignments. + Report match ids/accessions from gather results + that are not present in taxonomic assignments, either + by accident (missed) or request (skipped). """ - n_missed = 0 ident_missed= set() - for row in gather_results: - match_ident = row['name'] - match_ident = get_ident(match_ident) - if match_ident not in tax_assign: - n_missed += 1 - ident_missed.add(match_ident) - - notify(f'of {len(gather_results)}, missed {n_missed} lineage assignments.') - return n_missed, ident_missed - - -# pass ranks; have ranks=[default_ranks] -def make_krona_header(min_rank, *, include_strain=False): - "make header for krona output" - header = ["fraction"] - tl = list(taxlist(include_strain=include_strain)) - try: - rank_index = tl.index(min_rank) - except ValueError: - raise ValueError(f"Rank {min_rank} not present in available ranks!") - return tuple(header + tl[:rank_index+1]) - - -def aggregate_by_lineage_at_rank(rank_results, *, by_query=False): + ident_skipped= set() + total_n_missed = 0 + total_n_skipped = 0 + total_taxresults = 0 + for querytaxres in gather_results.values(): + ident_missed.update(querytaxres.missed_idents) + ident_skipped.update(querytaxres.skipped_idents) + # totals are total rows in gather that were missed - do we want to report these at all? + total_n_missed+= querytaxres.n_missed + total_n_skipped+= querytaxres.n_skipped + total_taxresults += len(querytaxres.raw_taxresults) + + if ident_missed: + notify(f'of {total_taxresults} gather results, lineage assignments for {total_n_missed} results were missed.') + notify(f'The following are missing from the taxonomy information: {", ".join(ident_missed)}') + + +def aggregate_by_lineage_at_rank(query_gather_results, rank, *, by_query=False): ''' - Aggregate list of rank SummarizedGatherResults, - keeping query info or aggregating across queries. + Aggregate list of summarized_lineage_results at rank, keeping + query names or not (but this aggregates across queries if multiple). ''' lineage_summary = defaultdict(float) if by_query: lineage_summary = defaultdict(dict) all_queries = [] - for res in rank_results: - if res.query_name not in all_queries: - all_queries.append(res.query_name) - if by_query: - lineage_summary[res.lineage][res.query_name] = res.fraction - else: - lineage_summary[res.lineage] += res.fraction - return lineage_summary, all_queries, len(all_queries) + for queryResult in query_gather_results: + query_name = queryResult.query_name + all_queries.append(query_name) + + if rank not in queryResult.summarized_ranks: + raise ValueError(f"Error: rank '{rank}' not available for aggregation.") + + for res in queryResult.summarized_lineage_results[rank]: + lineage = res.lineage.display_lineage(null_as_unclassified = True) + if by_query: + lineage_summary[lineage][query_name] = res.fraction # v5?: res.f_weighted_at_rank + else: + lineage_summary[lineage] += res.fraction -def format_for_krona(rank, summarized_gather): - ''' - Aggregate list of SummarizedGatherResults and format for krona output - ''' - num_queries=0 - for res_rank, rank_results in summarized_gather.items(): - if res_rank == rank: - lineage_summary, all_queries, num_queries = aggregate_by_lineage_at_rank(rank_results, by_query=False) # if aggregating across queries divide fraction by the total number of queries - for lin, fraction in lineage_summary.items(): - # divide total fraction by total number of queries - lineage_summary[lin] = fraction/num_queries + if not by_query: + n_queries = len(all_queries) + for lin, fraction in lineage_summary.items(): + lineage_summary[lin] = fraction/n_queries + return lineage_summary, all_queries - # sort by fraction - lin_items = list(lineage_summary.items()) - lin_items.sort(key = lambda x: -x[1]) - # reformat lineage for krona_results printing +def format_for_krona(query_gather_results, rank, *, classification=False): + ''' + Aggregate and format for krona output. Single query recommended, but we don't want query headers. + ''' + # make header + header = query_gather_results[0].make_krona_header(min_rank=rank) krona_results = [] - unclassified_fraction = 0 - for lin, fraction in lin_items: - # save unclassified fraction for the end - if lin == (): - unclassified_fraction = fraction - continue - lin_list = display_lineage(lin).split(';') - krona_results.append((fraction, *lin_list)) + # do we want to block more than one query for summarization? + if len(query_gather_results) > 1: + notify('WARNING: results from more than one query found. Krona summarization not recommended.\n' \ + 'Percentage assignment will be normalized by the number of queries to maintain range 0-100%.') + + if classification: + # for classification, just write the results + for q_res in query_gather_results: + if q_res.classified_ranks != [rank]: + q_res.build_classification_result(rank=rank) + header = q_res.make_krona_header(min_rank=rank) + # unclassified is 'correct' in that it is the part not classified to this match, + # but also misleading, since we're using best_only and there may + # be more matches that are not included here, making % unclassified seem higher than it would + # be with summarization. We previously excluded it -- is that the behavior we want to keep? + krona_results.extend([q_res.krona_classified])#, q_res.krona_unclassified]) + else: + lineage_summary, _ = aggregate_by_lineage_at_rank(query_gather_results, rank, by_query=False) + + # sort by fraction + lin_items = list(lineage_summary.items()) + lin_items.sort(key = lambda x: -x[1]) + + # reformat lineage for krona_results printing + unclassified_fraction = 0 + for lin, fraction in lin_items: + # save unclassified fraction for the end + if lin == "unclassified": + unclassified_fraction = fraction + continue + else: + lin_list = lin.split(';') + krona_results.append((fraction, *lin_list)) - # handle unclassified - if unclassified_fraction: - len_unclassified_lin = len(krona_results[-1]) -1 - unclassifed_lin = ["unclassified"]*len_unclassified_lin - krona_results.append((unclassified_fraction, *unclassifed_lin)) + # handle unclassified + if unclassified_fraction: + len_unclassified_lin = len(header) -1 + unclassifed_lin = ["unclassified"]*len_unclassified_lin + krona_results.append((unclassified_fraction, *unclassifed_lin)) - return krona_results + return krona_results, header -def write_krona(rank, krona_results, out_fp, *, sep='\t'): +def write_krona(header, krona_results, out_fp, *, sep='\t'): 'write krona output' - header = make_krona_header(rank) - tsv_output = csv.writer(out_fp, delimiter='\t') + # CTB: do we want to optionally allow restriction to a specific rank + # & above? NTP: think we originally kept krona to a specific rank, but + # that may have been how we were plotting, since krona plots can be + # hierarchical? Probably worth changing/extending to multilevel to + # take advantage of full krona plot features + tsv_output = csv.writer(out_fp, delimiter=sep) tsv_output.writerow(header) for res in krona_results: tsv_output.writerow(res) -def write_summary(summarized_gather, csv_fp, *, sep=',', limit_float_decimals=False): +def write_output(header, results, out_fp, *, sep=',', write_header=True): + """ + write pre-generated results list of rows, with each + row being a dictionary + """ + output = csv.DictWriter(out_fp, header, delimiter=sep) + if write_header: + output.writeheader() + for res in results: + output.writerow(res) + + +def write_bioboxes(header_lines, results, out_fp, *, sep='\t'): + """ + write pre-generated results list of rows, with each + row being list. + """ + for inf in header_lines: + out_fp.write(inf + '\n') + for res in results: + res = sep.join(res) + '\n' + out_fp.write(res) + + +def write_summary(query_gather_results, csv_fp, *, sep=',', limit_float_decimals=False, classification=False): ''' Write taxonomy-summarized gather results for each rank. ''' - header = SummarizedGatherResult._fields - w = csv.DictWriter(csv_fp, header, delimiter=sep) - w.writeheader() - for rank, rank_results in summarized_gather.items(): - for res in rank_results: - rD = res._asdict() - if limit_float_decimals: - rD['fraction'] = f'{res.fraction:.3f}' - rD['f_weighted_at_rank'] = f'{res.f_weighted_at_rank:.3f}' - rD['lineage'] = display_lineage(res.lineage) - if rD['lineage'] == "": - rD['lineage'] = "unclassified" - w.writerow(rD) - - -def write_classifications(classifications, csv_fp, *, sep=',', limit_float_decimals=False): - ''' - Write taxonomy-classifed gather results. + w= None + for q_res in query_gather_results: + header, summary = q_res.make_full_summary(limit_float=limit_float_decimals, classification=classification) + if w is None: + w = csv.DictWriter(csv_fp, header, delimiter=sep) + w.writeheader() + for res in summary: + w.writerow(res) + + +def write_human_summary(query_gather_results, out_fp, display_rank, classification=False): ''' - header = ClassificationResult._fields - w = csv.DictWriter(csv_fp, header, delimiter=sep) - w.writeheader() - for rank, rank_results in classifications.items(): - for res in rank_results: - rD = res._asdict() - if limit_float_decimals: - rD['fraction'] = f'{res.fraction:.3f}' - rD['f_weighted_at_rank'] = f'{res.f_weighted_at_rank:.3f}' - rD['lineage'] = display_lineage(res.lineage) - # needed? - if rD['lineage'] == "": - rD['lineage'] = "unclassified" - w.writerow(rD) - - -def combine_sumgather_csvs_by_lineage(gather_csvs, *, rank="species", accept_ranks = list(lca_utils.taxlist(include_strain=False)), force=False): + Write human-readable taxonomy-summarized gather results for a specific rank. ''' - Takes in one or more output csvs from `sourmash taxonomy summarize` - and combines the results into a nested dictionary with lineages - as the keys {lineage: {sample1: frac1, sample2: frac2}}. - Uses the file basename (minus .csv extension) as sample identifier. - - usage: + for queryResult in query_gather_results: + results = queryResult.make_human_summary(display_rank=display_rank, classification=classification) - linD, all_samples = combine_sumgather_by_lineage(["sample1.csv", "sample2.csv"], rank="genus") + if classification: + out_fp.write("sample name status proportion cANI lineage\n") + out_fp.write("----------- ------ ---------- ---- -------\n") - output: + for rD in results: + out_fp.write("{query_name:<15s} {status} {f_weighted_at_rank} {query_ani_at_rank} {lineage}\n".format(**rD)) + else: + out_fp.write("sample name proportion cANI lineage\n") + out_fp.write("----------- ---------- ---- -------\n") - linD = {lin_a: {'sample1': 0.4, 'sample2': 0.17, 'sample3': 0.6} - lin_b: {'sample1': 0.0, 'sample2': 0.0, 'sample3': 0.1} - lin_c: {'sample1': 0.3, 'sample2': 0.4, 'sample3': 0.2} } + for rD in results: + out_fp.write("{query_name:<15s} {f_weighted_at_rank} {query_ani_at_rank} {lineage}\n".format(**rD)) - all_samples = ['sample1','sample2','sample3'] +def write_lineage_sample_frac(sample_names, lineage_dict, out_fp, *, sep='\t'): ''' - if rank not in accept_ranks: - raise ValueError(f"Rank {rank} not available.") - - sgD = defaultdict(dict) - all_samples = [] - for g_csv in gather_csvs: - # collect lineage info for this sample - with open(g_csv, 'r') as fp: - r = csv.DictReader(fp) - for row in r: - if row["rank"] == rank: - query_name = row["query_name"] - lin = row["lineage"] - frac = row["fraction"] - if query_name not in all_samples: - all_samples.append(query_name) - sgD[lin][query_name] = frac - fp.close() - return sgD, all_samples - - -def write_lineage_sample_frac(sample_names, lineage_dict, out_fp, *, format_lineage=False, sep='\t'): - ''' - takes in a lineage dictionary with sample counts (output of combine_sumgather_by_lineage) + takes in a lineage dictionary with sample counts (output of aggregate_by_lineage_at_rank) and produces a tab-separated file with fractions for each sample. input: {lin_a: {sample1: 0.4, sample2: 0.17, sample3: 0.6} @@ -483,16 +943,13 @@ def write_lineage_sample_frac(sample_names, lineage_dict, out_fp, *, format_line blank_row = {query_name: 0 for query_name in sample_names} unclassified_row = None for lin, sampleinfo in sorted(lineage_dict.items()): - if format_lineage: - lin = display_lineage(lin) - #add lineage and 0 placeholders row = {'lineage': lin} row.update(blank_row) # add info for query_names that exist for this lineage row.update(sampleinfo) # if unclassified, save this row for the end - if not lin: + if lin== "unclassified": row.update({'lineage': 'unclassified'}) unclassified_row = row continue @@ -526,7 +983,7 @@ def __bool__(self): @classmethod def load(cls, filename, *, delimiter=',', force=False, - keep_full_identifiers=False, keep_identifier_versions=True): + keep_full_identifiers=False, keep_identifier_versions=True, lins=False): """ Load a taxonomy assignment CSV file into a LineageDB. @@ -546,8 +1003,7 @@ def load(cls, filename, *, delimiter=',', force=False, if os.path.isdir(filename): raise ValueError(f"'{filename}' is a directory") - with open(filename, newline='') as fp: - r = csv.DictReader(fp, delimiter=delimiter) + with sourmash_args.FileInputCSV(filename) as r: header = r.fieldnames if not header: raise ValueError(f'cannot read taxonomy assignments from {filename}') @@ -562,61 +1018,71 @@ def load(cls, filename, *, delimiter=',', force=False, elif 'accession' in header: identifier = 'accession' header = ["ident" if "accession" == x else x for x in header] + elif 'name' in header and 'lineage' in header: + return cls.load_from_gather_with_lineages(filename, + force=force, + lins=lins) else: - raise ValueError('No taxonomic identifiers found.') - # is "strain" an available rank? - if "strain" in header: - include_strain=True - - # check that all ranks are in header - ranks = list(lca_utils.taxlist(include_strain=include_strain)) - if not set(ranks).issubset(header): - # for now, just raise err if not all ranks are present. - # in future, we can define `ranks` differently if desired - # return them from this function so we can check the `available` ranks - raise ValueError('Not all taxonomy ranks present') + header_str = ",".join([repr(x) for x in header]) + raise ValueError(f'No taxonomic identifiers found; headers are {header_str}') + + if lins and "lin" not in header: + raise ValueError(f"'lin' column not found: cannot read LIN taxonomy assignments from {filename}.") + + if not lins: + # is "strain" an available rank? + if "strain" in header: + include_strain=True + # check that all ranks are in header + ranks = list(RankLineageInfo().taxlist) + if not include_strain: + ranks.remove('strain') + if not set(ranks).issubset(header): + # for now, just raise err if not all ranks are present. + # in future, we can define `ranks` differently if desired + # return them from this function so we can check the `available` ranks + raise ValueError('Not all taxonomy ranks present') assignments = {} num_rows = 0 n_species = 0 n_strains = 0 + n_pos = None # now parse and load lineages for n, row in enumerate(r): - if row: - num_rows += 1 - lineage = [] - # read row into a lineage pair - for rank in lca_utils.taxlist(include_strain=include_strain): - lin = row[rank] - lineage.append(LineagePair(rank, lin)) - ident = row[identifier] - - # fold, spindle, and mutilate ident? - if not keep_full_identifiers: - ident = ident.split(' ')[0] - - if not keep_identifier_versions: - ident = ident.split('.')[0] - - # clean lineage of null names, replace with 'unassigned' - lineage = [ (a, lca_utils.filter_null(b)) for (a,b) in lineage ] - lineage = [ LineagePair(a, b) for (a, b) in lineage ] - - # remove end nulls - while lineage and lineage[-1].name == 'unassigned': - lineage = lineage[:-1] - - # store lineage tuple - if lineage: - # check duplicates - if ident in assignments: - if assignments[ident] != tuple(lineage): - if not force: - raise ValueError(f"multiple lineages for identifier {ident}") - else: - assignments[ident] = tuple(lineage) - + num_rows += 1 + if lins: + lineageInfo = LINLineageInfo(lineage_str=row['lin']) + if n_pos is not None: + if lineageInfo.n_lin_positions != n_pos: + raise ValueError(f"For taxonomic summarization, all LIN assignments must use the same number of LIN positions.") + else: + n_pos = lineageInfo.n_lin_positions # set n_pos with first entry + ranks=lineageInfo.ranks + else: + # read lineage from row dictionary + lineageInfo = RankLineageInfo(lineage_dict=row) + # get identifier + ident = row[identifier] + + # fold, spindle, and mutilate ident? + ident = get_ident(ident, + keep_full_identifiers=keep_full_identifiers, + keep_identifier_versions=keep_identifier_versions) + + # store lineage tuple + lineage = lineageInfo.filled_lineage + if lineage: + # check duplicates + if ident in assignments: + if assignments[ident] != lineage: + if not force: + raise ValueError(f"multiple lineages for identifier {ident}") + else: + assignments[ident] = lineage + + if not lins: if lineage[-1].rank == 'species': n_species += 1 elif lineage[-1].rank == 'strain': @@ -626,6 +1092,70 @@ def load(cls, filename, *, delimiter=',', force=False, return LineageDB(assignments, ranks) + @classmethod + def load_from_gather_with_lineages(cls, filename, *, force=False, lins=False): + """ + Load an annotated gather-with-lineages CSV file produced by + 'tax annotate' into a LineageDB. + """ + include_strain = False + + if not os.path.exists(filename): + raise ValueError(f"'{filename}' does not exist") + + if os.path.isdir(filename): + raise ValueError(f"'{filename}' is a directory") + + with sourmash_args.FileInputCSV(filename) as r: + header = r.fieldnames + if not header: + raise ValueError(f'cannot read taxonomy assignments from {filename}') + + if "name" not in header or "lineage" not in header: + raise ValueError(f"Expected headers 'name' and 'lineage' not found. Is this a with-lineages file?") + + ranks=None + assignments = {} + num_rows = 0 + n_species = 0 + n_strains = 0 + + # now parse and load lineages + for n, row in enumerate(r): + num_rows += 1 + + name = row['name'] + ident = get_ident(name) + + if lins: + lineageInfo = LINLineageInfo(lineage_str=row['lineage']) + else: + lineageInfo = RankLineageInfo(lineage_str= row['lineage']) + + if ranks is None: + ranks = lineageInfo.taxlist + + lineage = lineageInfo.filled_lineage + # check duplicates + if ident in assignments: + if assignments[ident] != lineage: + # this should not happen with valid + # sourmash tax annotate output, but check anyway. + if not force: + raise ValueError(f"multiple lineages for identifier {ident}") + else: + assignments[ident] = lineage + + if isinstance(lineageInfo, RankLineageInfo): + if lineage[-1].rank == 'species': + n_species += 1 + elif lineage[-1].rank == 'strain': + n_species += 1 + n_strains += 1 + + return LineageDB(assignments, ranks) + + class LineageDB_Sqlite(abc.Mapping): """ A LineageDB based on a sqlite3 database with a 'sourmash_taxonomy' table. @@ -655,7 +1185,7 @@ def __init__(self, conn, *, table_name=None): # get available ranks... ranks = set() - for column, rank in zip(self.columns, taxlist(include_strain=True)): + for column, rank in zip(self.columns, RankLineageInfo().taxlist): query = f'SELECT COUNT({column}) FROM {self.table_name} WHERE {column} IS NOT NULL AND {column} != ""' c.execute(query) cnt, = c.fetchone() @@ -699,7 +1229,7 @@ def load(cls, location): def _make_tup(self, row): "build a tuple of LineagePairs for this sqlite row" - tup = [ LineagePair(n, r) for (n, r) in zip(taxlist(True), row) ] + tup = [ LineagePair(n, r) for (n, r) in zip(RankLineageInfo().taxlist, row) ] return tuple(tup) def __getitem__(self, ident): @@ -836,7 +1366,10 @@ def save(self, filename_or_fp, file_format): # we need a file handle; open file. fp = filename_or_fp if is_filename: - fp = open(filename_or_fp, 'w', newline="") + if filename_or_fp.endswith('.gz'): + fp = gzip.open(filename_or_fp, 'wt', newline="") + else: + fp = open(filename_or_fp, 'w', newline="") try: self._save_csv(fp) @@ -857,7 +1390,10 @@ def _save_sqlite(self, filename, *, conn=None): cursor = db.cursor() try: sqlite_utils.add_sourmash_internal(cursor, 'SqliteLineage', '1.0') + except sqlite3.OperationalError: + raise ValueError("attempt to write a readonly database") + try: # CTB: could add 'IF NOT EXIST' here; would need tests, too. cursor.execute(""" @@ -893,7 +1429,7 @@ class TEXT, db.commit() def _save_csv(self, fp): - headers = ['identifiers'] + list(taxlist(include_strain=True)) + headers = ['identifiers'] + list(RankLineageInfo().taxlist) w = csv.DictWriter(fp, fieldnames=headers) w.writeheader() @@ -914,6 +1450,8 @@ def _save_csv(self, fp): @classmethod def load(cls, locations, **kwargs): "Load one or more taxonomies from the given location(s)" + force = kwargs.get('force', False) + if isinstance(locations, str): raise TypeError("'locations' should be a list, not a string") @@ -934,14 +1472,850 @@ def load(cls, locations, **kwargs): try: this_tax_assign = LineageDB.load(location, **kwargs) loaded = True - except ValueError as exc: + except (ValueError, csv.Error) as exc: # for the last loader, just pass along ValueError... - raise ValueError(f"cannot read taxonomy assignments from '{location}': {str(exc)}") + if not force: + raise ValueError(f"cannot read taxonomy assignments from '{location}': {str(exc)}") # nothing loaded, goodbye! - if not loaded: + if not loaded and not force: raise ValueError(f"cannot read taxonomy assignments from '{location}'") - tax_assign.add(this_tax_assign) + if loaded: + tax_assign.add(this_tax_assign) return tax_assign + + +@dataclass +class GatherRow: + """ + Class to facilitate safely reading in Gather CSVs. The fields here should be match those + in "gather_write_cols" in `search.py` + + To ensure all columns required for taxonomic summarization are present, this class + contains no defaults for these columns and thus will throw a TypeError if any of these + columns are missing in the passed gather input. All other fields have default None. + + Usage: + + with sourmash_args.FileInputCSV(gather_csv) as r: + for row in enumerate(r): + gatherRow = GatherRow(**row) + """ + + # essential columns + query_name: str + name: str # match_name + f_unique_weighted: float + f_unique_to_query: float + unique_intersect_bp: int + remaining_bp: int + query_md5: str + query_filename: str + # new essential cols: requires 4.4x + query_bp: int + ksize: int + scaled: int + + # non-essential + intersect_bp: int = None + f_orig_query: float = None + f_match: float = None + average_abund: float = None + median_abund: float = None + std_abund: float = None + filename: str = None + md5: str = None + f_match_orig: float = None + gather_result_rank: str = None + moltype: str = None + query_n_hashes: int = None + query_abundance: int = None + query_containment_ani: float = None + match_containment_ani: float = None + average_containment_ani: float = None + max_containment_ani: float = None + potential_false_negative: bool = None + n_unique_weighted_found: int = None + sum_weighted_found: int = None + total_weighted_hashes: int = None + + +@dataclass +class QueryInfo: + "Class for storing query information" + query_name: str + query_md5: str + query_filename: str + query_bp: int + ksize: int + scaled: int + query_n_hashes: int = None + total_weighted_hashes: int = 0 + + def __post_init__(self): + "Initialize and cast types" + self.query_bp = int(self.query_bp) + self.ksize = int(self.ksize) + self.scaled = int(self.scaled) + self.query_n_hashes = int(self.query_n_hashes) if self.query_n_hashes else 0 + self.total_weighted_hashes = int(self.total_weighted_hashes) if self.total_weighted_hashes else 0 + + @property + def total_weighted_bp(self): + return self.total_weighted_hashes * self.scaled + + +@dataclass +class BaseTaxResult: + """ + Base class for sourmash taxonomic annotation. + """ + raw: dict # csv row + keep_full_identifiers: bool = False + keep_identifier_versions: bool = False + match_ident: str = field(init=False) + skipped_ident: bool = False + missed_ident: bool = False + match_lineage_attempted: bool = False + lins: bool = False + + def get_ident(self, id_col=None): + # split identifiers = split on whitespace + # keep identifiers = don't split .[12] from assembly accessions + "Hack and slash identifiers." + if id_col: + self.match_ident = self.raw[id_col] + else: + self.match_ident = self.raw.name + if not self.keep_full_identifiers: + self.match_ident = self.match_ident.split(' ')[0] + else: + #overrides version bc can't keep full without keeping version + self.keep_identifier_versions = True + if not self.keep_identifier_versions: + self.match_ident = self.match_ident.split('.')[0] + + + def get_match_lineage(self, tax_assignments, skip_idents=None, fail_on_missing_taxonomy=False): + if skip_idents and self.match_ident in skip_idents: + self.skipped_ident = True + else: + lin = tax_assignments.get(self.match_ident) + if lin: + if self.lins: + self.lineageInfo = LINLineageInfo(lineage = lin) + else: + self.lineageInfo = RankLineageInfo(lineage = lin) + else: + self.missed_ident=True + self.match_lineage_attempted = True + if self.missed_ident and fail_on_missing_taxonomy: + raise ValueError(f"Error: ident '{self.match_ident}' is not in the taxonomy database. Failing, as requested via --fail-on-missing-taxonomy") + + +@dataclass +class AnnotateTaxResult(BaseTaxResult): + """ + Class to enable taxonomic annotation of any sourmash CSV. + """ + id_col: str = 'name' + + def __post_init__(self): + if self.id_col not in self.raw.keys(): + raise ValueError(f"ID column '{self.id_col}' not found.") + self.get_ident(id_col=self.id_col) + if self.lins: + self.lineageInfo = LINLineageInfo() + else: + self.lineageInfo = RankLineageInfo() + + def row_with_lineages(self): + lineage = self.lineageInfo.display_lineage(truncate_empty=True) + rl = {"lineage": lineage} + rl.update(self.raw) + return rl + + +@dataclass +class TaxResult(BaseTaxResult): + """ + Class to store taxonomic result of a single row from a gather CSV, including accessible + query information (QueryInfo) and matched taxonomic lineage. TaxResult tracks whether + lineage matching has been attempted and whether the lineage matching failed + due to missing or skipped lineage identifiers. + + Initialize TaxResult using GatherRow, which ensures all required fields are present. + The QueryInfo in TaxResult is used to ensure only compatible gather results generated + from the same query are summarized during taxonomic summarization. + + Usage: + + with sourmash_args.FileInputCSV(gather_csv) as r: + for row in enumerate(r): + gatherRow = GatherRow(**row) + # initialize TaxResult + tax_res = TaxResult(raw=gatherRow) + + # get match lineage + tax_res.get_match_lineage(taxD=taxonomic_assignments) + + Use RankLineageInfo or LINLineageInfo to store lineage information. + """ + raw: GatherRow + query_name: str = field(init=False) + query_info: QueryInfo = field(init=False) + + def __post_init__(self): + self.get_ident() + self.query_name = self.raw.query_name # convenience + self.query_info = QueryInfo(query_name = self.raw.query_name, + query_md5=self.raw.query_md5, + query_filename = self.raw.query_filename, + query_bp = self.raw.query_bp, + query_n_hashes = self.raw.query_n_hashes, + total_weighted_hashes = self.raw.total_weighted_hashes, + ksize = self.raw.ksize, + scaled = self.raw.scaled + ) + # cast and store the imp bits + self.f_unique_to_query = float(self.raw.f_unique_to_query) + self.f_unique_weighted = float(self.raw.f_unique_weighted) + self.unique_intersect_bp = int(self.raw.unique_intersect_bp) + if self.lins: + self.lineageInfo = LINLineageInfo() + else: + self.lineageInfo = RankLineageInfo() + + +@dataclass +class SummarizedGatherResult: + """ + Class for storing summarized lineage information. + Automatically checks for out-of-range values and estimates ANI. + + Methods included for returning formatted results for different outputs. + """ + rank: str + fraction: float + lineage: RankLineageInfo + f_weighted_at_rank: float + bp_match_at_rank: int + query_ani_at_rank: float = None + + def __post_init__(self): + self.check_values() + + def check_values(self): + if any([self.fraction > 1, self.f_weighted_at_rank > 1]): + raise ValueError(f"Summarized fraction is > 100% of the query! This should not be possible. Please check that your input files come directly from a single gather run per query.") + # is this true for weighted too, or is that set to 0 when --ignore-abundance is used? + if any([self.fraction <=0, self.f_weighted_at_rank <= 0]): # this shouldn't actually happen, but it breaks ANI estimation, so let's check for it. + raise ValueError(f"Summarized fraction is <=0% of the query! This should not occur.") + + def set_query_ani(self, query_info): + self.query_ani_at_rank = containment_to_distance(self.fraction, query_info.ksize, query_info.scaled, + n_unique_kmers=query_info.query_n_hashes, + sequence_len_bp=query_info.query_bp).ani + + + def as_lineage_dict(self, query_info, ranks): + ''' + Format to dict for writing lineage-CSV file suitable for use with sourmash tax ... -t. + ''' + lD = {} + lD['ident'] = query_info.query_name + for rank in ranks: + lin_name = self.lineage.name_at_rank(rank) + if lin_name is None: + lin_name = "" + lD[rank] = lin_name + return lD + + def as_summary_dict(self, query_info, limit_float=False): + sD = asdict(self) + sD['lineage'] = self.lineage.display_lineage(null_as_unclassified=True) + sD['query_name'] = query_info.query_name + sD['query_md5'] = query_info.query_md5 + sD['query_filename'] = query_info.query_filename + sD['total_weighted_hashes'] = str(query_info.total_weighted_hashes) + sD['bp_match_at_rank'] = str(self.bp_match_at_rank) + if limit_float: + sD['fraction'] = f'{self.fraction:.3f}' + sD['f_weighted_at_rank'] = f'{self.f_weighted_at_rank:.3f}' + if self.query_ani_at_rank: + sD['query_ani_at_rank'] = f'{self.query_ani_at_rank:.3f}' + else: + sD['fraction'] = str(self.fraction) + sD['f_weighted_at_rank'] = str(self.f_weighted_at_rank) + + return(sD) + + def as_human_friendly_dict(self, query_info): + sD = self.as_summary_dict(query_info=query_info, limit_float=True) + sD['f_weighted_at_rank'] = f"{self.f_weighted_at_rank*100:>4.1f}%" + if self.query_ani_at_rank is not None: + sD['query_ani_at_rank'] = f"{self.query_ani_at_rank*100:>3.1f}%" + else: + sD['query_ani_at_rank'] = '- ' + return sD + + def as_kreport_dict(self, query_info): + """ + Produce kreport dict for named taxonomic groups. + """ + lowest_assignment_rank = 'species' + sD = {} + sD['num_bp_assigned'] = str(0) + sD['ncbi_taxid'] = None + # total percent containment, weighted to include abundance info + sD['percent_containment'] = f'{self.f_weighted_at_rank * 100:.2f}' + sD["num_bp_contained"] = str(int(self.f_weighted_at_rank * query_info.total_weighted_bp)) + if isinstance(self.lineage, LINLineageInfo): + raise ValueError("Cannot produce 'kreport' with LIN taxonomy.") + if self.lineage != RankLineageInfo(): + this_rank = self.lineage.lowest_rank + sD['rank_code'] = RANKCODE[this_rank] + sD['sci_name'] = self.lineage.lowest_lineage_name + taxid = self.lineage.lowest_lineage_taxid + if taxid: + sD['ncbi_taxid'] = str(taxid) + # the number of bp actually 'assigned' at this rank. Sourmash assigns everything + # at genome level, but since kreport traditionally doesn't include 'strain' or genome, + # it is reasonable to state that sourmash assigns at 'species' level for this. + # can be modified later. + if this_rank == lowest_assignment_rank: + sD["num_bp_assigned"] = sD["num_bp_contained"] + else: + sD['sci_name'] = 'unclassified' + sD['rank_code'] = RANKCODE['unclassified'] + sD["num_bp_assigned"] = sD["num_bp_contained"] + return sD + + def as_lingroup_dict(self, query_info, lg_name): + """ + Produce lingroup report dict for lingroups. + """ + sD = {} + # total percent containment, weighted to include abundance info + sD['percent_containment'] = f'{self.f_weighted_at_rank * 100:.2f}' + sD["num_bp_contained"] = str(int(self.f_weighted_at_rank * query_info.total_weighted_bp)) + sD["lin"] = self.lineage.display_lineage() + sD["name"] = lg_name + return sD + + def as_cami_bioboxes(self): + """ + Format taxonomy-summarized gather results + as CAMI profiling Bioboxes format. + + Columns are: TAXID RANK TAXPATH TAXPATHSN PERCENTAGE + """ + if isinstance(self.lineage, LINLineageInfo): + raise ValueError("Cannot produce 'bioboxes' with LIN taxonomy.") + if self.lineage != RankLineageInfo(): # if not unassigned + taxid = self.lineage.lowest_lineage_taxid + if taxid: + taxpath = self.lineage.display_taxid(sep="|") + taxid = str(taxid) + else: + taxpath = None + taxpathsn = self.lineage.display_lineage(sep="|") + percentage = f"{(self.f_weighted_at_rank * 100):.2f}" # fix at 2 decimal points + return [taxid, self.rank, taxpath, taxpathsn, percentage] + return [] + + +@dataclass +class ClassificationResult(SummarizedGatherResult): + """ + Inherits from SummarizedGatherResult + + Class for storing query classification information. + Automatically checks for out-of-range values and estimates ANI. + Checks classification status according to provided containment and ANI thresholds. + + Methods included for returning formatted results for different outputs. + """ + "Class for storing query classification information" + status: str = field(init=False) + + def __post_init__(self): + # check for out of bounds values, default "nomatch" if no match at all + self.check_values() + self.status = 'nomatch' #None? + + def set_status(self, query_info, containment_threshold=None, ani_threshold=None): + # if any matches, use 'below_threshold' as default; set 'match' if meets threshold + if any([containment_threshold is not None, ani_threshold is not None]): + self.status="below_threshold" + self.set_query_ani(query_info=query_info) + if ani_threshold is not None: # if provided, just use ani thresh, don't use containment threshold + if self.query_ani_at_rank >= ani_threshold: + self.status = 'match' + # v5?: switch to using self.f_weighted_at_rank here + elif containment_threshold is not None and self.fraction >= containment_threshold: + self.status = 'match' + + def build_krona_result(self, rank=None): + krona_classified, krona_unclassified = None, None + if rank is not None and rank == self.rank: + lin_as_list = self.lineage.display_lineage().split(';') + krona_classification = (self.fraction, *lin_as_list) # v5?: f_weighted_at_rank + krona_classified = (krona_classification) + # handle unclassified - do we want/need this? + unclassified_fraction= 1.0-self.fraction #v5?: f_weighted_at_rank + len_unclassified_lin = len(lin_as_list) + unclassifed_lin = ["unclassified"]*(len_unclassified_lin) + krona_unclassified = (unclassified_fraction, *unclassifed_lin) + return krona_classified, krona_unclassified + + +@dataclass +class QueryTaxResult: + """ + Class for storing all TaxResults (gather results rows) for a query. + Checks query compatibility prior to adding a TaxResult. + Stores raw TaxResults and provides methods for summarizing up ranks + and reporting these summarized results as metagenome summaries or + genome classifications. + + Contains methods for formatting results for different outputs. + """ + query_info: QueryInfo # initialize with QueryInfo dataclass + lins: bool = False + + def __post_init__(self): + self.query_name = self.query_info.query_name # for convenience + self._init_taxresult_vars() + self._init_summarization_vars() + self._init_classification_results() + + def _init_taxresult_vars(self): + self.ranks = [] + self.raw_taxresults = [] + self.skipped_idents= set() + self.missed_idents = set() + self.n_missed = 0 + self.n_skipped = 0 + self.perfect_match = set() + + def _init_summarization_vars(self): + self.sum_uniq_weighted = defaultdict(lambda: defaultdict(float)) + self.sum_uniq_to_query = defaultdict(lambda: defaultdict(float)) + self.sum_uniq_bp = defaultdict(lambda: defaultdict(int)) + self.summarized_ranks = [] + self._init_summarization_results() + + def _init_summarization_results(self): + self.total_f_weighted = defaultdict(float) #0.0 + self.total_f_classified = defaultdict(float)#0.0 + self.total_bp_classified = defaultdict(int) #0 + self.summarized_lineage_results = defaultdict(list) + + def _init_classification_results(self): + self.status = 'nomatch' + self.classified_ranks = [] + self.classification_result = None + self.krona_classified = None + self.krona_unclassified = None + self.krona_header = [] + + def is_compatible(self, taxresult): + return taxresult.query_info == self.query_info and taxresult.lins == self.lins + + @property + def ascending_ranks(self): + if not self.ranks: + return [] + else: + return self.ranks[::-1] + + def add_taxresult(self, taxresult): + # check that all query parameters match + if self.is_compatible(taxresult=taxresult): + if not taxresult.match_lineage_attempted: + raise ValueError("Error: Cannot add TaxResult. Please use get_match_lineage() to add taxonomic lineage information first.") + if not self.ranks: + self.ranks = taxresult.lineageInfo.ranks + if taxresult.skipped_ident: + self.n_skipped +=1 + self.skipped_idents.add(taxresult.match_ident) + elif taxresult.missed_ident: + self.n_missed +=1 + self.missed_idents.add(taxresult.match_ident) + self.raw_taxresults.append(taxresult) + else: + raise ValueError("Error: Cannot add TaxResult: query information does not match.") + + def summarize_up_ranks(self, single_rank=None, force_resummarize=False): + if self.summarized_ranks: # has already been summarized + if force_resummarize: + self._init_summarization_vars() + else: + raise ValueError("Error: already summarized using rank(s): '{', '.join(self.summarized_ranks)}'. Use 'force_resummarize=True' to reset and resummarize") + # set ranks levels to summarize + self.summarized_ranks = self.ascending_ranks + if single_rank: + if single_rank not in self.summarized_ranks: + raise ValueError(f"Error: rank '{single_rank}' not in available ranks ({', '.join(self.summarized_ranks)})") + self.summarized_ranks = [single_rank] + notify(f"Starting summarization up rank(s): {', '.join(self.summarized_ranks)} ") + for taxres in self.raw_taxresults: + lininfo = taxres.lineageInfo + if lininfo and lininfo.filled_lineage: # won't always have lineage to summarize (skipped idents, missed idents) + # notify + track perfect matches + if taxres.f_unique_to_query >= 1.0: + if taxres.match_ident not in self.perfect_match: + notify(f"WARNING: 100% match! Is query '{self.query_name}' identical to its database match, '{taxres.match_ident}'?") + self.perfect_match.add(taxres.match_ident) + # add this taxresult to summary + for rank in self.summarized_ranks: + if rank in lininfo.filled_ranks: # only store if this rank is filled. + lin_at_rank = lininfo.pop_to_rank(rank) + self.sum_uniq_weighted[rank][lin_at_rank] += taxres.f_unique_weighted + self.sum_uniq_to_query[rank][lin_at_rank] += taxres.f_unique_to_query + self.sum_uniq_bp[rank][lin_at_rank] += taxres.unique_intersect_bp + # reset ranks levels to the ones that were actually summarized + that we can access for summarized result + self.summarized_ranks = [x for x in self.summarized_ranks if x in self.sum_uniq_bp.keys()] + if single_rank and single_rank not in self.summarized_ranks: + raise ValueError(f"Error: rank '{single_rank}' was not available for any matching lineages.") + + def build_summarized_result(self, single_rank=None, force_resummarize=False): + # just reset if we've already built summarized result (avoid adding to existing)? Or write in an error/force option? + self._init_summarization_results() + # if taxresults haven't been summarized, do that first + if not self.summarized_ranks or force_resummarize: + self.summarize_up_ranks(single_rank=single_rank, force_resummarize=force_resummarize) + # catch potential error from running summarize_up_ranks separately and passing in different single_rank + if single_rank and single_rank not in self.summarized_ranks: + raise ValueError(f"Error: rank '{single_rank}' not in summarized rank(s), {','.join(self.summarized_ranks)}") + # rank loop is currently done in __main__ + for rank in self.summarized_ranks[::-1]: # reverse so that results are in descending order + sum_uniq_to_query = self.sum_uniq_to_query[rank] #should be lineage: value + # first, sort + sorted_sum_uniq_to_query = list(sum_uniq_to_query.items()) + sorted_sum_uniq_to_query.sort(key = lambda x: -x[1]) + for lineage, f_unique in sorted_sum_uniq_to_query: + # does this ever happen? do we need it? + if f_unique == 0: #no annotated results for this query. do we need to handle this differently now? + continue + f_weighted_at_rank = self.sum_uniq_weighted[rank][lineage] + bp_intersect_at_rank = self.sum_uniq_bp[rank][lineage] + sres = SummarizedGatherResult(lineage=lineage, rank=rank, + f_weighted_at_rank=f_weighted_at_rank, fraction=f_unique, + bp_match_at_rank=bp_intersect_at_rank) + sres.set_query_ani(query_info=self.query_info) + self.summarized_lineage_results[rank].append(sres) + + # NTP Note: These change by rank ONLY when doing best_only (selecting top hit at that particular rank) + # now that I pulled best_only into separate fn, these don't need to be dicts... + self.total_f_classified[rank] += f_unique + self.total_f_weighted[rank] += f_weighted_at_rank + self.total_bp_classified[rank] += bp_intersect_at_rank + + # record unclassified + if self.lins: + lineage = LINLineageInfo() + else: + lineage = RankLineageInfo() + query_ani = None + f_unique = 1.0 - self.total_f_classified[rank] + if f_unique > 0: + f_weighted_at_rank = 1.0 - self.total_f_weighted[rank] + bp_intersect_at_rank = self.query_info.query_bp - self.total_bp_classified[rank] + sres = SummarizedGatherResult(lineage=lineage, rank=rank, f_weighted_at_rank=f_weighted_at_rank, + fraction=f_unique, bp_match_at_rank=bp_intersect_at_rank, query_ani_at_rank=query_ani) + self.summarized_lineage_results[rank].append(sres) + + def build_classification_result(self, rank=None, ani_threshold=None, containment_threshold=0.1, force_resummarize=False, lingroup_ranks=None, lingroups=None): + if containment_threshold is not None and not 0 <= containment_threshold <= 1: + raise ValueError(f"Containment threshold must be between 0 and 1 (input value: {containment_threshold}).") + if ani_threshold is not None and not 0 <= ani_threshold <= 1: + raise ValueError(f"ANI threshold must be between 0 and 1 (input value: {ani_threshold}).") + self._init_classification_results() # init some fields + if not self.summarized_ranks or force_resummarize: + self.summarize_up_ranks(single_rank=rank, force_resummarize=force_resummarize) + # catch potential error from running summarize_up_ranks separately and passing in different single_rank + self.classified_ranks = self.summarized_ranks + # if a rank is provided, we need to classify ONLY using that rank + if rank: + if rank not in self.summarized_ranks: + raise ValueError(f"Error: rank '{rank}' not in summarized rank(s), {','.join(self.summarized_ranks)}") + else: + self.classified_ranks = [rank] + if lingroup_ranks: + notify("Restricting classification to lingroups.") + self.classified_ranks = [x for x in self.classified_ranks if x in lingroup_ranks] + if not self.classified_ranks: + raise ValueError(f"Error: no ranks remain for classification.") + # CLASSIFY using summarization--> best only result. Best way = use ANI or containment threshold + classif = None + for this_rank in self.classified_ranks: # ascending order or just single rank + # reset for this rank + f_weighted=0.0 + f_unique_at_rank=0.0 + bp_intersect_at_rank=0 + sum_uniq_to_query = self.sum_uniq_to_query[this_rank] + # sort the results and grab best + sorted_sum_uniq_to_query = list(sum_uniq_to_query.items()) + sorted_sum_uniq_to_query.sort(key = lambda x: -x[1]) + # select best-at-rank only + this_lineage, f_unique_at_rank = sorted_sum_uniq_to_query[0] + # if in desired lineage groups, continue (or??) + if lingroups and this_lineage not in lingroups: + # ignore this lineage and continue up + continue + bp_intersect_at_rank = self.sum_uniq_bp[this_rank][this_lineage] + f_weighted = self.sum_uniq_weighted[this_rank][this_lineage] + + classif = ClassificationResult(rank=this_rank, fraction=f_unique_at_rank, lineage=this_lineage, + f_weighted_at_rank=f_weighted, bp_match_at_rank=bp_intersect_at_rank) + + classif.set_status(self.query_info, containment_threshold=containment_threshold, ani_threshold=ani_threshold) + # determine whether to move on to a higher tax rank (if avail) + if classif.status == 'match' or classif.status == "nomatch": # not sure we want/need the `nomatch` part... + break + + # store the final classification result + self.classification_result = classif + # could do this later, in __main__.py, for example + self.krona_classified, self.krona_unclassified = self.classification_result.build_krona_result(rank=rank) + self.krona_header = self.make_krona_header(min_rank = rank) + + def make_krona_header(self, min_rank): + "make header for krona output" + if min_rank is None: + return [] + if min_rank not in self.summarized_ranks: + raise ValueError(f"Rank '{min_rank}' not present in summarized ranks.") + else: + rank_index = self.ranks.index(min_rank) + return ["fraction"] + list(self.ranks[:rank_index+1]) + + def check_classification(self): + if not self.classification_result: + raise ValueError("query not classified yet.") + + def check_summarization(self): + if not self.summarized_lineage_results: + raise ValueError("lineages not summarized yet.") + + def make_human_summary(self, display_rank, classification=False): + results = [] + if classification: + self.check_classification() + display_rank_results = [self.classification_result] + else: + self.check_summarization() + display_rank_results = self.summarized_lineage_results[display_rank] + display_rank_results.sort(key=lambda res: -res.f_weighted_at_rank) + + for res in display_rank_results: + results.append(res.as_human_friendly_dict(query_info=self.query_info)) + return results + + def make_full_summary(self, classification=False, limit_float=False): + results = [] + rD = {} + if classification: + self.check_classification() + header= ["query_name", "status", "rank", "fraction", "lineage", + "query_md5", "query_filename", "f_weighted_at_rank", + "bp_match_at_rank", "query_ani_at_rank"] + rD = self.classification_result.as_summary_dict(query_info = self.query_info, limit_float=limit_float) + del rD['total_weighted_hashes'] + results.append(rD) + else: + self.check_summarization() + header= ["query_name", "rank", "fraction", "lineage", "query_md5", + "query_filename", "f_weighted_at_rank", "bp_match_at_rank", + "query_ani_at_rank", "total_weighted_hashes"] + + for rank in self.summarized_ranks[::-1]: #descending + unclassified=[] + rank_results = self.summarized_lineage_results[rank] + rank_results.sort(key=lambda res: -res.fraction) #v5?: f_weighted_at_rank) + for res in rank_results: + rD = res.as_summary_dict(query_info=self.query_info, limit_float=limit_float) + # save unclassified for the end + if rD['lineage'] == "unclassified": + unclassified.append(rD) + else: + results.append(rD) + results +=unclassified + return header, results + + def make_kreport_results(self): + ''' + Format taxonomy-summarized gather results as kraken-style kreport. + + STANDARD KREPORT FORMAT: + - `Percent Reads Contained in Taxon`: The cumulative percentage of reads for this taxon and all descendants. + - `Number of Reads Contained in Taxon`: The cumulative number of reads for this taxon and all descendants. + - `Number of Reads Assigned to Taxon`: The number of reads assigned directly to this taxon (not a cumulative count of all descendants). + - `Rank Code`: (U)nclassified, (R)oot, (D)omain, (K)ingdom, (P)hylum, (C)lass, (O)rder, (F)amily, (G)enus, or (S)pecies. + - `NCBI Taxon ID`: Numerical ID from the NCBI taxonomy database. + - `Scientific Name`: The scientific name of the taxon. + + Example reads-based `kreport` with all columns: + ``` + 88.41 2138742 193618 K 2 Bacteria + 0.16 3852 818 P 201174 Actinobacteria + 0.13 3034 0 C 1760 Actinomycetia + 0.13 3034 45 O 85009 Propionibacteriales + 0.12 2989 1847 F 31957 Propionibacteriaceae + 0.05 1142 352 G 1912216 Cutibacterium + 0.03 790 790 S 1747 Cutibacterium acnes + ``` + + SOURMASH KREPORT FORMAT: + + To best represent the sequence dataset, please build sourmash signatures with abundance tracking + to enable utilization of sequence abundance information during sourmash gather and taxonomic summarization. + + While this format typically records the percent of number of reads assigned to taxa, + we can create comparable output by reporting the percent of base pairs (percent containment) + the total number of base pairs matched. Using sourmash default scaled values, these numbers + will be estimates from FracMinHash k-mer comparisons. If using sourmash scaled=1 + (not recommended for most use cases), these results will be based on all k-mers. + + `sourmash gather` assigns k-mers to individual genoems. Since the lowest kreport rank is + "species," we use the "Assigned to Taxon" column to report assignments summarized to species level. + + - `Percent Contained in Taxon`: Percent of all base pairs contained by this taxon (weighted by abundance if tracked) + - `Estimated base pairs Contained in Taxon`: Number of base pairs contained by this taxon (weighted by abundance if tracked) + - `Estimated base pairs Assigned to Taxon`: Number of base pairs at species-level (weighted by abundance if tracked) + - `Rank Code`: (U)nclassified, (R)oot, (D)omain, (K)ingdom, (P)hylum, (C)lass, (O)rder, (F)amily, (G)enus, or (S)pecies. + - `NCBI Taxon ID` will not be reported (blank entries). + - `Scientific Name`: The scientific name of the taxon. + + In the future, we may wish to report the NCBI taxid when we can (NCBI taxonomy only). + ''' + self.check_summarization() + header = ["percent_containment", "num_bp_contained", "num_bp_assigned", "rank_code", "ncbi_taxid", "sci_name"] + if self.query_info.total_weighted_hashes == 0: + raise ValueError("ERROR: cannot produce 'kreport' format from gather results before sourmash v4.5.0") + required_ranks = set(RANKCODE.keys()) + acceptable_ranks = list(self.ranks) + ['unclassified', 'kingdom'] + if not required_ranks.issubset(set(acceptable_ranks)): + raise ValueError("ERROR: cannot produce 'kreport' format from ranks {', '.join(self.ranks)}") + kreport_results = [] + unclassified_recorded=False + # want to order results descending by rank + for rank in self.ranks: + if rank == 'strain': # no code for strain, can't include in this output afaik + continue + rank_results = self.summarized_lineage_results[rank] + for res in rank_results: + kresD = res.as_kreport_dict(self.query_info) + if kresD['sci_name'] == "unclassified": + # SummarizedGatherResults have an unclassified lineage at every rank, to facilitate reporting at a specific rank. + # Here, we only need to report it once, since it will be the same fraction for all ranks + if unclassified_recorded: + continue + else: + unclassified_recorded = True + kreport_results.append(kresD) + return header, kreport_results + + def make_lingroup_results(self, LINgroupsD): # LingroupsD is dictionary {lg_prefix: lg_name} + """ + Report results for the specified LINGroups. + Keep LCA paths in order as much as possible. + """ + self.check_summarization() + header = ["name", "lin", "percent_containment", "num_bp_contained"] + + if self.query_info.total_weighted_hashes == 0: + raise ValueError("ERROR: cannot produce 'lingroup' format from gather results before sourmash v4.5.0") + + # find the ranks we need to consider + lg_ranks, all_lgs = parse_lingroups(LINgroupsD) + + # grab summarized results matching LINgroup prefixes + lg_results = {} + for rank in lg_ranks: + rank_results = self.summarized_lineage_results[rank] + for res in rank_results: + if res.lineage in all_lgs:# is this lineage in the list of LINgroups? + this_lingroup_name = LINgroupsD[res.lineage.display_lineage(truncate_empty=True)] + lg_resD = res.as_lingroup_dict(self.query_info, this_lingroup_name) + lg_results[res.lineage] = lg_resD + + # We want to return in ~ depth order: descending each specific path in order + # use LineageTree to find ordered paths + lg_tree = LineageTree(all_lgs) + ordered_paths = lg_tree.ordered_paths(include_internal = True) + # store results in order: + lingroup_results=[] + for lg in ordered_paths: + # get LINInfo object + lg_LINInfo = LINLineageInfo(lineage=lg) + # get result, if we have it + lg_res = lg_results.get(lg_LINInfo) + if lg_res: + lingroup_results.append(lg_res) + + return header, lingroup_results + + def make_cami_bioboxes(self): + """ + info: https://github.com/CAMI-challenge/contest_information/blob/master/file_formats/CAMI_TP_specification.mkd + + columns: + TAXID - specifies a unique alphanumeric ID for a node in a reference tree such as the NCBI taxonomy + RANK - superkingdom --> strain + TAXPATH - the path from the root of the reference taxonomy to the respective taxon + TAXPATHSN - scientific names of taxpath + PERCENTAGE (0-100) - field specifies what percentage of the sample was assigned to the respective TAXID + + example: + + #CAMI Submission for Taxonomic Profiling + @Version:0.9.1 + @SampleID:SAMPLEID + @Ranks:superkingdom|phylum|class|order|family|genus|species|strain + + @@TAXID RANK TAXPATH TAXPATHSN PERCENTAGE + 2 superkingdom 2 Bacteria 98.81211 + 2157 superkingdom 2157 Archaea 1.18789 + 1239 phylum 2|1239 Bacteria|Firmicutes 59.75801 + 1224 phylum 2|1224 Bacteria|Proteobacteria 18.94674 + 28890 phylum 2157|28890 Archaea|Euryarchaeotes 1.18789 + 91061 class 2|1239|91061 Bacteria|Firmicutes|Bacilli 59.75801 + 28211 class 2|1224|28211 Bacteria|Proteobacteria|Alphaproteobacteria 18.94674 + 183925 class 2157|28890|183925 Archaea|Euryarchaeotes|Methanobacteria 1.18789 + 1385 order 2|1239|91061|1385 Bacteria|Firmicutes|Bacilli|Bacillales 59.75801 + 356 order 2|1224|28211|356 Bacteria|Proteobacteria|Alphaproteobacteria|Rhizobacteria 10.52311 + 204455 order 2|1224|28211|204455 Bacteria|Proteobacteria|Alphaproteobacteria|Rhodobacterales 8.42263 + 2158 order 2157|28890|183925|2158 Archaea|Euryarchaeotes|Methanobacteria|Methanobacteriales 1.18789 + """ + # build CAMI header info + header_title = "# Taxonomic Profiling Output" + version_info = "@Version:0.10.0" + program = "@__program__:sourmash" + sample_info = f"@SampleID:{self.query_info.query_name}" + # taxonomy_id = "@TaxonomyID:2021-10-01" # store this with LineageDB, maybe? + ranks = list(self.ranks) + # if 'strain' in ranks: + # ranks.remove('strain') + rank_info = f"@Ranks:{'|'.join(ranks)}" + + header_lines = [header_title, sample_info, version_info, rank_info, program] + colnames = ["@@TAXID","RANK","TAXPATH","TAXPATHSN","PERCENTAGE"] + header_lines.append('\t'.join(colnames)) + + # now build results in CAMI format + bioboxes_results = [] + # order results by rank (descending), then percentage + for rank in ranks: + rank_results = self.summarized_lineage_results[rank] + for res in rank_results: + bb_info = res.as_cami_bioboxes() + if bb_info: + bioboxes_results.append(bb_info) + + return header_lines, bioboxes_results + diff --git a/src/sourmash/utils.py b/src/sourmash/utils.py index 555833d9c1..71afc20261 100644 --- a/src/sourmash/utils.py +++ b/src/sourmash/utils.py @@ -6,7 +6,7 @@ attached_refs = weakref.WeakKeyDictionary() -class RustObject(object): +class RustObject: __dealloc_func__ = None _objptr = None _shared = False diff --git a/tests/conftest.py b/tests/conftest.py index ad249537ca..3281133cd5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ import os +import sys from hypothesis import settings, Verbosity import pytest @@ -7,7 +8,7 @@ plt.rcParams.update({'figure.max_open_warning': 0}) from sourmash_tst_utils import TempDirectory, RunnerContext - +sys.stdout = sys.stderr @pytest.fixture def runtmp(): @@ -74,6 +75,10 @@ def lca_db_format(request): def manifest_db_format(request): return request.param +@pytest.fixture(params=['sig', 'sig.gz', 'zip', '.d/', '.sqldb']) +def sig_save_extension(request): + return request.param + # --- BEGIN - Only run tests using a particular fixture --- # # Cribbed from: http://pythontesting.net/framework/pytest/pytest-run-tests-using-particular-fixture/ diff --git a/tests/sourmash_tst_utils.py b/tests/sourmash_tst_utils.py index 19f2f045f5..07582b0dc2 100644 --- a/tests/sourmash_tst_utils.py +++ b/tests/sourmash_tst_utils.py @@ -6,13 +6,21 @@ import subprocess import collections import pprint - -import pkg_resources -from pkg_resources import Requirement, resource_filename, ResolutionError import traceback from io import open # pylint: disable=redefined-builtin from io import StringIO +from importlib import resources +from importlib.metadata import entry_points + +# Remove when we drop support for 3.8 +if sys.version_info < (3, 9): + import importlib_resources as resources + +# Remove when we drop support for 3.9 +if sys.version_info < (3, 10): + from importlib_metadata import entry_points + SIG_FILES = [os.path.join('demo', f) for f in ( "SRR2060939_1.sig", "SRR2060939_2.sig", "SRR2241509_1.sig", @@ -44,9 +52,10 @@ def _runscript(scriptname): namespace['sys'] = globals()['sys'] try: - pkg_resources.load_entry_point("sourmash", 'console_scripts', scriptname)() + (script,) = entry_points(name=scriptname, group="console_scripts") + script.load()() return 0 - except pkg_resources.ResolutionError: + except ValueError: pass path = scriptpath() @@ -129,14 +138,8 @@ def runscript(scriptname, args, **kwargs): def get_test_data(filename): - filepath = None - try: - filepath = resource_filename( - Requirement.parse("sourmash"), "sourmash/sourmash/test-data/"\ - + filename) - except ResolutionError: - pass - if not filepath or not os.path.isfile(filepath): + filepath = resources.files("sourmash") / "tests" / "test-data" / filename + if not filepath.exists() or not os.path.isfile(filepath): filepath = os.path.join(os.path.dirname(__file__), 'test-data', filename) return filepath @@ -187,7 +190,7 @@ def run_sourmash(self, *args, **kwargs): kwargs['in_directory'] = self.location cmdlist = ['sourmash'] - cmdlist.extend(args) + cmdlist.extend(( str(x) for x in args)) self.last_command = " ".join(cmdlist) self.last_result = runscript('sourmash', args, **kwargs) diff --git a/tests/test-data/47-63-merge.sig b/tests/test-data/47-63-merge.sig new file mode 100644 index 0000000000..813a9ea35b --- /dev/null +++ b/tests/test-data/47-63-merge.sig @@ -0,0 +1 @@ +[{"class":"sourmash_signature","email":"","hash_function":"0.murmur64","filename":null,"name":"union of 47 and 63 with sig merge","license":"CC0","signatures":[{"num":0,"ksize":31,"seed":42,"max_hash":18446744073709552,"mins":[2925290528259,4550472317399,7397951382043,9478766578752,13950946236093,18944997319943,23965553957178,26390034908046,31811219567311,36191627174349,39112643786682,39116644913079,45540178190062,46822418898135,47180432856748,47367865367011,50188964065828,60017138985701,60046869099761,65325381835497,71315379183989,73805228897455,74037001801154,75800414195236,76190207226452,76733150364607,81855770871884,83631867214312,86442965329695,89858161015356,90806331781332,95108107091043,97258972896665,101072116806558,109728134835863,111162670259148,113585458770972,116166720583475,117764718466551,121382935674939,125296899385152,126550040779683,131219583539670,135497977128744,138537198116911,141176320451685,141284968207060,141805235471354,144854809628393,147190179068733,149024066888166,153783847123278,154055696317370,157255282423883,159984546139167,160902593196961,162823771630571,166163367169365,173817709369233,174979625787948,175032069345452,182141449646872,186033286918724,187503667710897,189372881188038,191814288543916,192890223167288,194756913683551,195186364664284,196037984804395,196583081113717,197033160819668,197511952689884,203335269479450,204822233856042,209233297808434,210990374921109,214600505227173,216861451361880,217827490079709,220853041487844,220923489229742,224612774123844,227683744949779,228540468204721,228644441858825,228848037454334,235388211895357,235478348737722,240579984608212,244969519314153,245029062906088,248581735034297,251186192464160,256982811990450,258255664267571,258924003299576,265587486568360,266208269658242,269368370464968,274235329360733,287831833139065,293035680952788,294558365931778,295357672887769,303548064148961,303884611876696,306041902505698,307504482597750,307854560177526,308368577694671,309969810458414,312508185357838,316701230964482,316897730501733,318378982650332,318568418472400,318769251839299,319335385321196,324290895668437,328294702120808,333432074251696,335605928681508,337243852456192,337788124062930,338296399583721,339214912158009,341001360876621,341707613453589,349282729367235,349347535027435,351258437206186,360629153151276,360700437330047,364874268407945,367327558614874,370407120162642,370595193569972,371700279077049,373471575728001,374188784960382,380562917179168,381400955129871,384016070832594,385242214102144,386412107814027,389279696836396,393985777467936,394797505266453,395356088720884,396054053894786,399215565767837,399215750881719,401601210843243,411030707384650,411114364557568,413011122735307,414934253467214,423759820188444,423964724934678,426125170072817,430191392037330,431262729545883,431514229123485,437290714565213,437506450440821,438105428895659,438530381378884,439044119332850,444196295289556,444273467310604,444519598728863,449680755457024,450506164772110,457138551847407,457671098462976,461477875054528,473505790593386,479169447884417,481815788294090,487479264340595,489519873805078,494381455384554,495601542123242,499291264639906,500121418104925,502603922576313,502817538324491,506180131137999,506336140549160,514225777616243,516283812540815,517767750601700,518803929727716,528657857026586,531621018151405,531911437550984,536385923460112,536713413896697,537757852470225,538244971589768,540208451183188,540588787405694,542763181011925,549192277958979,550069279000761,551298730569376,553354665477159,553900351455263,554447489693319,559226934389812,560183238975729,561316274253850,565120453207608,569061433009767,578397933313938,578899728742280,582924953100697,583572058199369,584608245878528,589631402773527,595681341596523,597246199104774,598744731403748,600409846627222,615537076898013,618082707842705,622540719875971,623112432173232,623564286058606,626475972546369,629214339204581,630564503284386,632243908286808,635842404828277,636454008801855,637963474663087,639824119626438,645743921515803,648018730756195,651565303681692,654100189449365,658694830193748,659359272720991,668365295793413,670476914395432,670523964406925,671677182717796,671759739945458,676515376899555,677670347980377,681398943911705,684837528099741,687743951317806,687895771489510,693383660447783,693758846688308,694523064126211,697547171219962,698360853391060,698383699159430,698714184393962,699304671955329,699414001750549,702028024437135,702655743925993,703696716274708,706932232475763,708449170262947,712467878162751,715119064036805,717098217279451,720002464362774,726292867622433,726450649964317,727685109931740,727786563214130,727800693698567,728799639190186,732669904526146,734019394597526,734554562642182,735119835330596,736734785664407,737721455578775,738157827715680,738543439712395,741951415758063,748275069435017,750168693442959,755428520351338,760914553470842,763201112060730,763669867104092,763903450865190,766769547808498,767121298622699,767417571203746,770436202573059,771683466150501,772051111454828,772152509572841,772219237112224,773253991876737,773640117883594,778719831734053,787291725467630,788541066253130,792800620274218,793126364353395,798319271383660,801192084570519,801979719204052,802162977380527,806030682122869,806341566938246,813805466325024,815570804752811,816564335333987,817024725405204,817504754626588,821706687072387,826029246264765,826077010431743,828356750400476,831736232379626,833989830989481,842811510495576,843025850509368,843740928711723,845050451776051,850325922079257,852042280696332,852616072150159,857912135260852,869440701256868,871739984144307,871829709114624,873254290207218,873878962991102,875246525542985,885732713802105,889897273652095,894040289596463,897620767964532,900984329565111,904962988643425,905308801557271,906900833647951,909442865612931,912697620927191,913789208155712,916185332282483,917277762192278,917334002968300,919561883055202,920956096920505,923846800950923,927733683191258,929046426661708,930950142910172,931188320066855,933691189676382,934117578798841,934829751321026,936230738064974,938188383682602,941382244559733,941861412444067,942726201014166,945032973428091,947084478373286,948088536983824,948779805509636,950251614016922,951217347666850,953311674641424,955636489177710,961017555998937,961314440978493,964218423186297,965844189572501,968212926455014,968926587713112,969379511837489,972618046502811,974258465646756,974637708612999,980196796037373,980565419407507,983225283458250,986147003709405,987407484206883,987541215674501,992940514834332,995584120548026,996549857630112,998926194132937,1000937685539434,1013410477239192,1014496787753945,1017704359447639,1020480845863237,1024292399670426,1024634573363382,1028460419483054,1033874047074353,1035843403340873,1037163054983442,1039558325527817,1040756065933066,1045088944681707,1045785088974313,1048574231977270,1051002783372661,1051364708814767,1056506578664023,1057491059487351,1057829590018958,1059199927925541,1059437143082343,1059853068042602,1060460577561031,1060760398971021,1061967838052170,1062598334481497,1066520357980609,1069224019506529,1071759691375436,1072369963153950,1075576974119467,1076194665589715,1076307473155886,1080440645655398,1083957482733017,1085596610204486,1086288713384900,1088705827145973,1089204340626863,1090298523330765,1090505634288396,1092749687333011,1093123453947031,1093780160574614,1094807962005299,1096801323900100,1103535113750718,1105423537109674,1106831352465275,1110277142974534,1113000955148039,1118646614510530,1119281509125641,1119614160374606,1120783033143617,1127740241225995,1129279349995602,1130113935525204,1130881986044393,1132820492214112,1141800182705337,1141957433258847,1144614443668767,1147223276986948,1147680055727668,1153321625273873,1154225306393882,1158195764117399,1163159397520386,1163303408022562,1164535774717695,1167323239732974,1171405306002670,1174386415542665,1175414485855042,1177706510708587,1182168703505980,1185081332506550,1186019430315229,1187776202203922,1188893606724299,1190006012882786,1191391064481088,1197375422019658,1200797929442729,1202982399470063,1203248128742846,1208460365112124,1216183572752189,1216349299615743,1217760228928876,1225631809302250,1229437984652935,1232027701338500,1234453656762891,1234698668275227,1237451114108962,1241245219164313,1241546710850109,1241668290204495,1242418821754022,1243711623939695,1244290020173228,1244346278691061,1245003263018464,1247153420491991,1250484435790357,1256754510605581,1258709409617632,1261831186742139,1264563878337445,1269060350975578,1270154727600023,1271923497273997,1273115659423672,1278259882696625,1280559509676354,1282472909138162,1285880210646676,1287498565406779,1291218968991828,1292246474868788,1292487278268025,1294492972700075,1296553378083571,1300214247397513,1300724144234399,1304554095644208,1306972413335965,1310819837267255,1310835683752449,1311078551896352,1311449533649890,1313445683077905,1313826623773576,1314579091305857,1320340005211507,1322276316890973,1325524051301607,1327738097851136,1332430917176015,1332675238905364,1334939013056183,1337600826833551,1338522631065263,1339674524726757,1341661245836409,1341942310569850,1344850241954264,1346444314218596,1348761416973437,1351570661987737,1355274635861570,1356904466129199,1358877729391906,1362862766300078,1367870608065681,1369078449955986,1370039456672284,1370746553808697,1370854092951821,1371431531962106,1372351037556570,1376501003787476,1377792461855597,1378471035008080,1383348406006914,1387085462947589,1387385057191781,1388584147493453,1390384276015810,1396964107951550,1400398296107299,1401298565016323,1407177952341116,1407230822931784,1410486644494794,1410786461048450,1414537954260326,1414568563679456,1417984842495635,1418743862991832,1420081602859846,1423937616614171,1424366051167663,1429198692194983,1431140791675340,1432423484009425,1438960590550765,1443327278633039,1443983103542619,1449677011803774,1450861996451357,1452845116328517,1455020177231177,1457429906997387,1458641089226597,1458716224614631,1459144447544839,1461511802747479,1465076638017898,1465867789405739,1466391743844010,1467827467674025,1468663744355213,1470580811951805,1472835164326678,1476939334625119,1480237325649862,1480539429325015,1481088686107013,1483592564337201,1484226042988529,1492314148312178,1492550216115797,1497353541354036,1498457281550692,1499289039133439,1499617447616390,1503853002568292,1504262319315651,1505096985478881,1505172916296130,1507126472384732,1509264042384987,1513668920373911,1514218641840864,1519203024139040,1519648405600154,1520931632741619,1521780633605083,1522237249746592,1522582599941917,1523518586763814,1523791718968068,1528071377900249,1529728378502178,1531967467499308,1535306641925593,1535451811798960,1535658178776979,1537397570523640,1538118443000138,1539307118095840,1545740833177405,1546368847550532,1548019688923957,1552083355029650,1553692785951588,1554071056896812,1555637141656241,1555928090783844,1556284449775147,1558324681023092,1559450514209843,1560969323307091,1561680258145617,1569318833056381,1573222947937990,1580482970750366,1584949879718000,1588956381434551,1588978430427079,1591204462547614,1592943214430038,1596940098556068,1598261363578814,1599643181368601,1600494020961221,1600688746972553,1602101064988014,1603093475242546,1605199952752847,1608467868376532,1613230042071034,1617237167349710,1618568234848372,1619286790649678,1620827732630844,1621192910003941,1622309948672121,1623966948730132,1624318503960075,1626639019435247,1628201100274523,1632271494883561,1633815225207084,1634399357702189,1637441524349088,1638727302192255,1642353838656017,1642637371934077,1643195637784435,1643453621271585,1643598557356785,1645422696089427,1645673596073883,1645866259200502,1648716913052297,1652765950688817,1654033476941478,1658104204669725,1658743399661231,1659819081077302,1662433005161059,1662485779876221,1666913529898081,1669812089421733,1679605700468270,1679921198649960,1680089532480362,1682746046505721,1683724693448022,1686298869637573,1688481990617362,1689682641477370,1690389336617199,1691171280116985,1691706033392643,1695663032921673,1696089597402537,1699809983366070,1705950022227142,1709048685481388,1712457316704754,1712827460605989,1714068118984789,1717459770518422,1718014979380734,1719016742757524,1719690455811654,1720716306077049,1731588874078275,1734728075132632,1737929931793243,1739172733710985,1742230705053471,1744646337710934,1745324659468599,1747303538361662,1749145577098552,1750021468273833,1750530525839386,1750848466069991,1757623281396842,1758538630442116,1761579455667380,1762621869823670,1766019454242846,1766154871452422,1768074570558590,1769506068128510,1770988073934927,1772504621686320,1778201561133905,1778315567513725,1780288814569870,1781805678833298,1786019351090790,1787025898307575,1793358709247570,1793456012680375,1797142702056877,1797145955746037,1797367475778719,1800147823225056,1800236363852916,1800292312232821,1801404378718274,1802232213372715,1804215890133513,1806220280793222,1808123394894591,1808685369384027,1808805306365691,1810435102767883,1812018140268588,1813163351446427,1818925318022107,1819091566970620,1821246620845572,1825289420275521,1829618500803507,1831822327838518,1832408978761242,1832446933549908,1833926924520737,1835694527640110,1837046808494825,1840320929072049,1842060817177608,1844561134226776,1845664541012305,1846495837486874,1846814283210937,1848195902901531,1849342199305473,1852955637970413,1853064829868822,1854709332537365,1856791461736081,1858474922519628,1860030910962345,1862153320764207,1862209616890144,1867578456400407,1870278489144074,1871450013370760,1873261502540924,1880743049410508,1880811582956504,1885064900552256,1885528658573969,1888527800896759,1894756346141601,1899912419788159,1900541751009950,1901209651803518,1901291971170255,1901876532182375,1902791616557208,1908153712615768,1909513665427200,1909893462067689,1910324702460153,1911464021342251,1917279203885894,1920155014152585,1921273856324485,1923031184773399,1923724551213831,1928488418125995,1930938144553240,1934075675835507,1934298906146492,1935392806238480,1936981590066389,1937494292258243,1939414707320138,1940094016678579,1940349446359845,1941935226774825,1942786308149620,1943195916125611,1943419695090025,1946261780746826,1947698435893922,1947827395290642,1948000063884420,1951286173673455,1957196594968485,1957568197413107,1961156417600790,1961412299515744,1968345824207972,1973565525696890,1976759223622041,1977637922131648,1977923456470816,1977965122461301,1979621033784766,1979691191211071,1980374385622179,1981752378561978,1986523359754220,1987210877457747,1988980021478572,1993564537623510,1996672784729607,1999133750243675,2011354377485272,2012874059058878,2014266663706516,2017517839581062,2021272596821928,2022546455157095,2024291985865500,2026552431475137,2030916441428059,2030943399237635,2035149501864507,2038730607060260,2042859086816241,2047630125224977,2050342367296606,2052394950437991,2055202564805434,2059880114534091,2060560658024761,2061302384344128,2064432037950349,2064820419581597,2083858695302000,2087593304215351,2088434760658037,2092860563281190,2094083749006424,2095578868362462,2095898151245418,2097280377232511,2099121913442760,2099462838437830,2103311335893154,2104210209064238,2105296453788320,2107076373938295,2108459225069649,2111395821264557,2111781716293290,2122246048824157,2123504523298871,2125171930737142,2127588293738580,2128337797349122,2135890156111278,2137755589431662,2142250867012638,2142644727013638,2147601242872786,2147628766136779,2148277682163663,2148629935713334,2148648462894137,2151003212774236,2154050039033300,2154825108832254,2157543511093753,2159292319817060,2159391483345580,2159559528918973,2160176312000315,2160324368330875,2162577581063898,2163811550162994,2163911364872485,2164107595577716,2166610246026701,2169130162448361,2169401527323023,2176988842247011,2181533165000933,2182965638264818,2187598499331444,2189722857501195,2189902950844361,2190906838663940,2191089458213993,2194321556975056,2197255584699767,2197550753498976,2197844428920029,2199416705656232,2204899458948058,2207484772689862,2211251305477479,2214585329667475,2216291576857764,2218560589085471,2222579004644118,2225440067596925,2230429649730384,2230499997873756,2231678593259696,2233374767701498,2237267871356954,2239881880935087,2246651203996116,2249026723401047,2249382176770011,2251553784168898,2252148298922995,2262343143065292,2269891656332884,2277357511613050,2282851679505524,2284008883123690,2284430348204299,2289542722841620,2291237331288700,2294311150128150,2295851772366195,2296330477067902,2297300047218453,2297432045933256,2299522719885844,2300003729256754,2305986746818130,2307128673346491,2309328595812376,2311901346223486,2314348683023278,2318110633163391,2318934737046572,2324238767058056,2325178911253636,2337363146012963,2338273922165178,2340650536569632,2341149645621931,2341499707179628,2347050480883180,2348231529227808,2349169861378449,2349507317182586,2349825918922019,2350362296145783,2350865952696907,2355373744763135,2359599974602456,2360988166250281,2364165589013103,2365100930739182,2365101583995089,2368070257601382,2368316853240052,2375746870819817,2377178146794550,2379436665071024,2382084067881576,2385503149484476,2385595359290641,2386455839922464,2387981834215976,2391376217204289,2391657757985839,2392696292165175,2398709852888712,2400026944838468,2402208725828096,2406393959209312,2409550806440554,2409651860889698,2410681029165949,2412591449989948,2413992919514685,2416801677490020,2416809210551017,2420810333651625,2426508439798144,2431689886658063,2432443095396045,2434636409776451,2434817960891416,2439360431069834,2442924938559564,2444743697540746,2447704465950372,2451185988965285,2451335219119778,2459766139292236,2466530448132713,2466909570912171,2468169126671752,2469990435969385,2472082629869597,2484069800626695,2484707593134371,2486433068244510,2486783619425529,2489988128759413,2494166617182106,2496262488317655,2497895029394563,2498928723235105,2502877897637973,2503728397420013,2504935182899326,2507836460937176,2510936514907563,2514588516976413,2516300104537043,2516615505374811,2529686136078992,2529757512285754,2531603179656151,2535379300081535,2536540298562196,2537131228360750,2540748246632844,2542081767873586,2543849372306944,2545009932051689,2547409441873208,2556506799873846,2556532058925046,2562322499824421,2566619437831302,2569272697162382,2570487229611126,2573496573602154,2573521798941261,2575271828359827,2577332320388602,2582286025195040,2583040424187016,2583468225494252,2583541506767529,2588404706278861,2590282004204866,2593389934385770,2594360169365119,2594709561160407,2597669806698660,2598736648640020,2599462021052813,2600008000392449,2600325642328760,2601526047213631,2604919231758350,2606628075888049,2607585442845824,2608738783833234,2611080508323464,2611611650962181,2615637595599978,2618806127233677,2620375519634887,2623788431218018,2625753537877756,2626910805885551,2629378920027678,2630791796680663,2633023374568506,2636060748463825,2641610347651893,2642503504311045,2642896503944011,2647702649265595,2651833968467605,2658287974480506,2661391357250546,2662023298318235,2665833107218149,2668521248016496,2671313026437821,2671398445852750,2675525837460390,2677246953723931,2678336928677512,2680497019271975,2681476812723673,2681688690783031,2683246311949145,2693519224664396,2696615422431379,2697093257227995,2698689094049392,2703119946699707,2707831053578465,2708218678481553,2715915477263655,2716578540554404,2729224055534831,2731006551655845,2732055421730493,2734700729661142,2737421785282245,2738859769218570,2742018183825055,2742141972024096,2743081343023861,2743888467937942,2751785297738513,2752147920224584,2757079557005164,2758379916840243,2758720834995819,2758979243701204,2759926223357146,2761431364716452,2764250636697336,2765183622501164,2766608515295278,2771029262532041,2771167327169082,2771385955352873,2774929020087483,2787100655005297,2789215679189400,2794658818174763,2794721456334777,2796704110243902,2798287443905216,2798322134667150,2819699168008416,2822847943723684,2823296594306435,2823690427545053,2824112773494385,2825639876500433,2827393174086051,2828383286324950,2830986947810877,2832223063283424,2839310794637108,2839525055055156,2840131111024087,2845086895593857,2848124500620503,2850303080077087,2850392763711528,2851615637093918,2852640851512226,2857548210960220,2858440556030254,2859863407402383,2859967373892809,2860695977896818,2868567544019176,2868722569049791,2869950221815451,2874478035430398,2875394822256464,2875768840498356,2876952463837377,2877071122530509,2881697295591721,2885101058817579,2888998206990875,2889009360623809,2889740392149462,2892327330159456,2892701240258741,2893195916828713,2893601424052339,2896177891093468,2899892307914794,2903470979230250,2904033648694661,2905361464861211,2906932245374500,2908444575023598,2911124480176230,2911599257101941,2921971427799899,2924433965541354,2927511611173972,2928681715178239,2932316419447329,2932335002147816,2941649814209169,2942563058582163,2946143480195981,2946208695315985,2946321521010318,2947035710205399,2948810955001129,2950640741434545,2950953756094034,2952664814352903,2952714905200090,2953540238859553,2956213777269798,2956539890231005,2958358510714643,2959335482526692,2959566715003402,2961679237758947,2965352172439193,2965439340704221,2965855909473064,2965950633977302,2966360689949309,2973978384800223,2974389005017021,2975093256580654,2975414282596751,2977316941548719,2977663445217111,2983750706789338,2984126693118897,2985749744661602,2993577870679042,2995240248615334,2997216190337734,2997475303842149,2998782348202460,2999606817239118,3000133614988345,3002144816290295,3004601117971759,3005445693257298,3005543539398257,3006068427214846,3006825969228148,3007008399287583,3007436553703536,3009353123519576,3009879725684408,3011102094194039,3014955625381722,3016063581505292,3024440815482041,3029401793589254,3035569961506260,3035917551240430,3037029199949908,3037243067032125,3044971913771990,3049610843618123,3052077662817141,3055458832740035,3056598953438835,3058282989031583,3060978444911194,3061176921317878,3063484099651207,3072032250423585,3074112617890076,3076040588704705,3079164924470365,3083176156972821,3083894323700064,3084542708185897,3086123334924126,3087642706090516,3089319907683113,3095652405939026,3098540992604022,3101693309309556,3103506635288743,3103723994300125,3104057316763627,3110882600220192,3112903104807973,3114065706400821,3120960716235347,3125993645599853,3126462642335525,3127233513015486,3127239755462313,3127515727740291,3132674664095953,3135911618150412,3136116654223153,3138943638252170,3140104823595207,3153220014750330,3155618345518179,3158074449437715,3160234728942373,3164386809673569,3173337239261818,3173783571944417,3175607440806275,3180308189083804,3184825950572980,3187782335419440,3190439500089370,3191603569657769,3192369183577062,3193298901760522,3193346797759720,3194861056078031,3196931220104868,3199730308565073,3201112500492023,3202793460581380,3202842543008174,3215163473676920,3216620904572420,3219171897424954,3219575037594274,3223165789803264,3224535741992415,3225652520990690,3226382984631204,3230186294385431,3242521498997924,3243947082373306,3244627180010006,3248922800662151,3250589381907882,3251201051516647,3253156487699363,3255370232763973,3257337304537355,3265267353090335,3272928147712512,3273345746404244,3273455155749328,3275046616104436,3275876017733599,3276795248980256,3276867668269439,3278668472321042,3278714300330291,3280780868469494,3280794856819360,3282504976610485,3285404600033524,3289047034753180,3291688382088694,3294550813104021,3298089165637310,3299027911208090,3306709449273253,3309878495036042,3312036573217165,3315214714717367,3316733753186879,3320827905894255,3320924500756368,3321523572005253,3322765801173894,3323347352904912,3331435560359461,3332103278994362,3333652145199727,3335087116356546,3335131382305523,3336332604419491,3339857783542570,3346073604734971,3346508186700073,3349764598731466,3359742817020406,3359946222588919,3362158184502934,3362515004859132,3363044683709605,3365198599599379,3365586794581106,3366181769304978,3367829027870594,3369243531861603,3377357612999215,3378502887959344,3379556656256325,3380271898400315,3380377839647911,3380683064402177,3381845747007120,3382194366074501,3382728295376857,3383075891087465,3383302464154854,3383677243861212,3384640865212142,3390004150029672,3390849838258698,3392713809155776,3396510059796443,3402399487500761,3402608390039987,3404656276789459,3405135139021774,3405149696809115,3405393044390619,3406073672541821,3408568306290700,3409127489900273,3409525642139599,3409848562939689,3415139398115166,3415475306791216,3417433407494643,3418693183078260,3419312829124670,3421667659970361,3425688845571110,3430483417325813,3431010040648861,3433786393292948,3433893111687059,3434270543355054,3439118720682675,3442408921142835,3442892234692145,3443405929340821,3443814552613298,3444188614792340,3444648483822568,3450598427318554,3452335392026500,3454079754241547,3458841677994973,3459090134521778,3463857637926984,3466717314703766,3466754010283942,3468005713457978,3468306935523998,3469621823753300,3476218946140045,3477671288317241,3482209838567431,3484417521606056,3484887355924665,3485163641925480,3485345404315595,3485684351025169,3486271024140478,3486481363201290,3489716365352044,3490218326835149,3493226173405941,3495557286227599,3496034375933097,3496927759647254,3501438911492802,3501605155319176,3502652434217727,3506373791709328,3508110471552336,3509064061394091,3509072379429744,3522367029305364,3523617734835555,3523958917267613,3525633782648726,3526392686886988,3531208508664527,3532553615695946,3535903464263126,3539565874451621,3539901139312850,3540376200850317,3541349497756661,3544819360646120,3549947024705822,3552108775050946,3556230614643794,3561873326260814,3567631654480233,3569439133907194,3569585416963919,3571316365062188,3574306253032309,3576162331242874,3580260758329980,3584444323393668,3586259833614913,3591860672649222,3598107344839577,3599693059706844,3600497750427469,3600830075661157,3609452627397093,3611915615904413,3613882437854401,3621421484619367,3622877473302676,3624379228154857,3628580997551890,3629216192438886,3632115466215379,3633516585258144,3634906107194620,3636013168823101,3644503150656777,3645611623206895,3647071999472194,3648486681146863,3648590415100172,3650411848640853,3651208352182968,3652128421562312,3656809320682474,3659962641982274,3663160485730853,3665217012698891,3670531861702815,3671760492215784,3672142134405475,3675775119596083,3677241624891815,3678142299238288,3679412885698189,3679723249740163,3681869903468458,3686489672677129,3688280883691690,3688327457349314,3690688866820810,3693932188146288,3705003329123112,3717407943997266,3718621708258333,3719578355011258,3720637385401222,3722526932992524,3723634868396071,3726938239845979,3728715138302811,3730126559106774,3734192362762123,3737946633507459,3740458701179796,3742193997053523,3742234596917242,3746284516790765,3748081334630687,3750533866251628,3752113241891676,3752117756365521,3756936323992755,3757543406733882,3762029049082585,3762486477132181,3762751701280063,3764820504193506,3765408152696993,3767814128506980,3773610790058654,3774512692458152,3776595480654768,3776811730885528,3779767620249001,3784106736164980,3785218675925174,3786173858770873,3791490899732442,3792618824893518,3794100680281451,3794855359477272,3797500278748845,3797572839534654,3801836630743327,3806047581097738,3807583511277136,3808139090545696,3809879441266392,3810799390411918,3817002980250587,3821170295134013,3821583371411656,3821630362687996,3822296640796331,3823175201590864,3825757657900286,3827430957263349,3827560739565438,3831533818899493,3835198273307888,3835558444026950,3835953940403171,3837899280987896,3838735004282207,3841522241190425,3842446209291097,3854418337562031,3856495945466312,3856632581492180,3860116438298861,3862694398476978,3863189469668600,3863738703644495,3869850399187705,3870413121329386,3871935007496414,3872121521356678,3872628172162502,3873324719285632,3878833882038024,3880243619746497,3880529063199350,3882311402640088,3887523678289264,3893564413662650,3895543606604936,3899793396857493,3907221612214493,3908280143480975,3908511683767038,3911082399615065,3917275362273600,3919329961567492,3921698054272911,3921777467979712,3925362829074370,3928994435189027,3929160579967105,3929583967036139,3934142879673460,3934625885954180,3935344037868472,3941494939757571,3941525993199884,3942048398609850,3949319172964121,3949404714704001,3951447621965404,3955893898658897,3968940236457600,3969914685581241,3972341462705556,3973437909773411,3974439738742238,3974884051240316,3977362201597748,3979182335035265,3980515012130917,3981349448512541,3982398317594569,3983688318248752,3983919240708090,3988823560476515,3991304962417620,3996232480056804,4001338100305267,4003000776821491,4010634521845832,4010843628806486,4011194441900352,4012917626427041,4014500024147982,4016225827426866,4018474899910568,4020678940249116,4020893102640326,4023604165179706,4026092267698298,4026688740814878,4031918591272287,4034351439123543,4034809919461185,4036574510586483,4037588403031850,4039922936524250,4039932863104502,4040392164753436,4040574608674437,4041139896587433,4041872990193158,4044662871102224,4049368939697671,4049703973786608,4051543922389363,4052681338148215,4053851627999337,4055364557376134,4060744208423493,4062261174287869,4065415697051189,4068573234330842,4071633959541762,4073835315653206,4076846825357774,4079083296648701,4081004427180528,4086687744224011,4089226616645108,4094432968332287,4097469103782424,4097518435638924,4102324633120593,4103314051061542,4107887608841281,4114248458913135,4114343888383846,4119621966564488,4120642288889696,4122077681924969,4123781140489537,4132460402529320,4132938192487530,4134892291048521,4135928574382122,4138840172908252,4139231196428117,4143693188392502,4146607129757067,4150745968454974,4151459023776703,4154333523980713,4154752706236746,4154870399231204,4156105039099431,4157256439982237,4157669782790617,4161040214839097,4165710956052375,4166107478621219,4167383773226728,4167492383925201,4168181927338698,4172772824248916,4175996866082730,4176623816804364,4177011602951503,4180091758040141,4181901601551230,4183391742729367,4183402718643845,4188349160298046,4192843946846883,4195677986920473,4197298171025153,4198198981311457,4199209071018538,4199346559716278,4199937026193161,4200142600556427,4201374728073667,4210305409366342,4211040452351221,4214771019264212,4215582065649959,4219986862600637,4225632034684502,4228066473660175,4228645799747418,4234174233250830,4238632143676429,4245383804030219,4245913779845337,4253320341425011,4255227426589464,4255700538587916,4256775531402776,4257521149254292,4259508008844151,4264631484544901,4266389306044662,4267533238822472,4275580175408244,4276068588949150,4289359014707288,4291309135357292,4291681176800862,4295704004397925,4297182086971339,4300982165488644,4301841948469269,4302934567016197,4305626904573311,4312242836327385,4316505852417381,4316904893589599,4316945036052182,4317078081505616,4317422028288966,4319428928391780,4320671705521862,4320948899808113,4321895279026225,4329077654347637,4330412836235513,4332389830901236,4332584479772575,4335310014667247,4337120565239620,4340454370272718,4345864924315697,4346218796838410,4349990949165001,4352222894063447,4354953199641044,4356160430961353,4357880027267574,4367125746948875,4369068622168572,4371927952461526,4373860960273698,4374872954793723,4377274169565988,4381988283585309,4382694020890333,4383134213334340,4387047294147332,4388622108830575,4393722386432944,4394935128907327,4396253792760348,4397522735467217,4405185846773600,4409442890926800,4411810134796150,4418995118878418,4419676464130546,4421125787216995,4425233123315500,4430853131113411,4433648774646017,4434408204046953,4435949176623047,4437545167361411,4438317402421127,4438817177523704,4443045313246981,4444824473102486,4445062840118079,4445273578631201,4454575698901762,4458591608285019,4458728897870062,4461351844352989,4462449521144694,4464228910638153,4466692510919550,4467196354294999,4467568428111961,4473513246905919,4474000580782956,4474046785524256,4474574743148389,4481408122328948,4484145294717318,4484402946653100,4486507708773899,4486975012628738,4495575342843287,4496415696378542,4497702687497656,4499856296475689,4500028349770179,4500630405545048,4501876131664304,4508459014863643,4517133177825796,4525521793503217,4528035909846301,4528214817956822,4532427908015373,4535830530899372,4538230925800141,4539716842839588,4541577773165123,4543700870914372,4551316076842289,4552805172103424,4561703129830313,4562273139429756,4562889929649950,4565834153211579,4572477816005275,4573722402896435,4573883165195550,4577060828696911,4577457918883209,4577958025008691,4580199770736665,4580353634394552,4582265498314074,4585370103466467,4589368018406258,4595427440105692,4595548152987374,4598596070144322,4602434211109390,4606699947177076,4607450449118254,4609112952479794,4613982013879466,4620004533537053,4621113398888425,4621781324879751,4622580510893583,4625645650398470,4630992980149087,4634141648884370,4634249736656772,4638719988526137,4640301477916105,4640518507314105,4641399081470667,4641458089041250,4641794721319090,4641810465552112,4645238665720809,4645621983164383,4646458337623997,4647556566493222,4651299169613798,4652026280650077,4653873516606103,4658079460193832,4663702386772812,4665559789434328,4665715316999671,4668242832840992,4669236331860436,4670796977014993,4671025914890237,4671062938394354,4676205735481526,4676683626882003,4678953511037049,4681240613933899,4683821965014649,4689320811962225,4689342516749982,4690119446223188,4692908964849533,4694128337468791,4696124657031960,4696193088102148,4696638008353613,4698423835133356,4699216184918082,4704195358103927,4708213524509388,4719261186528920,4721521787903217,4723274946162868,4728819432363533,4729163773640834,4730341942998122,4730493691532331,4737131202167177,4739486119895556,4745352351350096,4745940331555049,4747354617127312,4750145321126258,4751422453008817,4755286169023364,4755841017162123,4755912323473330,4756833761182793,4758803188341003,4760074424405483,4762437486337017,4762834014218571,4769979165221157,4776830747495994,4778822146835476,4780869466523606,4784620939372924,4784854530655115,4784960269821774,4785601845773156,4786683724318639,4788364915970531,4791650559342688,4792800308786051,4793495469956659,4794337453617434,4797047238512497,4800291638880957,4803115735233530,4804645672015140,4805893216574402,4806679764920158,4806827593856676,4811729290308862,4813583810073804,4817878202402319,4819210711953623,4819297775674748,4819311564829320,4822240770685261,4823251614359045,4825298177502391,4825955485244615,4827011615233835,4828117842424667,4828732317464211,4833444690765931,4835736860545211,4836894122787451,4838221388703602,4842656966937005,4848071008956361,4848960069162027,4856643780511233,4857129012656990,4859353321888294,4859467776000605,4859849392390603,4861078197128753,4865270528419335,4867360470630358,4868100857196342,4869271395674487,4875972042816124,4878610157475814,4879638248944748,4881531428270387,4883085485434756,4894597037736842,4900275904853327,4901450877660648,4902345078498684,4907673099841830,4911078209866511,4912787048821119,4913269610096771,4914549573455980,4917312639789602,4927397100040965,4930034114903088,4934031744913600,4938885956719683,4938984906671371,4941067275317682,4949578709897161,4952413633841153,4953773437884696,4957117546097581,4962024566226233,4964966617138828,4966376651170584,4973778510774167,4975226570860151,4975540053830624,4978106676024424,4986110732910751,4995563329299788,4996212995257738,4999915977157470,5002056492229942,5006774552951727,5006929223505003,5009560616993369,5011891458604349,5012633125949878,5012847090206147,5017724733800167,5018157783395788,5018781410893851,5019357482030347,5021333195096243,5021445876086138,5023785116027497,5030617336717801,5032209299980010,5033368364296409,5038003571725954,5045163363224076,5047944681561823,5054098670441464,5056182885286156,5057486321357458,5057790328506277,5059239413878415,5059653728314562,5066776986395776,5073647426884964,5075357793289723,5078143579563766,5082075970958360,5086177235816634,5086440629066369,5087182732073845,5090517135844571,5092533829369071,5093869377835176,5095238786157913,5095997925642684,5096759450835327,5097502836207144,5097991563922208,5101214857653244,5102026127818781,5102715480695317,5102867437873560,5107792502157973,5108455108876502,5109339895416818,5111881437192243,5112547786374962,5113517669186741,5113987619419017,5124695659957290,5125513112408495,5126251354950045,5127124574162351,5127331696245969,5128951178677788,5132612621833970,5143420500944709,5145276514713692,5146076759962970,5146830226631178,5148881101936222,5149206177910233,5149437337079666,5149863467137139,5150315306295015,5150426384165948,5152031727525643,5154406455748760,5156097648832284,5156461422732999,5157379504662047,5159084802671573,5161207050871469,5161248283909416,5164496015188591,5168741136958562,5169183503623442,5170033828188437,5172451986737288,5173899903805393,5174341244024506,5174660761082943,5175517983450337,5178681082547978,5185496711050665,5189447618805589,5199397406461572,5199519828192191,5210093218542459,5216652140931560,5222047064350262,5222282340592980,5225960701910860,5226896358843829,5230003976759540,5231985318055496,5232675033262932,5236341345649495,5238635836185856,5239269458643567,5241779126216109,5242274139089145,5247586236105385,5251993594243967,5256157883002967,5260023793294245,5260441842663055,5262424254200249,5262515077905251,5265232429826960,5269315881525070,5271118414757758,5272010381461258,5273272937856228,5276753284521288,5277050637122870,5284015351506042,5291703312055669,5298034705538719,5302053963299700,5304145080779837,5304463365763770,5304711911200062,5306712077765058,5312331308812989,5313230992694743,5314446308863251,5316323217920338,5318153535798629,5319128074583642,5325429115684951,5326372158895078,5327554389775897,5328891577748554,5329749554849635,5333028765846132,5333350248619291,5333588429074391,5333952601578012,5334935043856488,5335020091722251,5340420023836909,5344450649206065,5345704507186657,5347417569933680,5349744287355670,5349788779068053,5351104001242138,5351378986429919,5358493250319346,5360234560170868,5361787265106398,5363374768894673,5364846600059577,5367133911213099,5367718245082904,5370226527358712,5372175647130534,5375274967884629,5379052587485175,5379968233240165,5380736206240325,5381546956953785,5381644047795232,5383368125410553,5385746119086850,5386810090653851,5388894770243354,5390748522455977,5394080989067132,5396776081740647,5396868798166091,5397913443318485,5398598214938688,5400291293650351,5401392461039875,5403110884625308,5406614253824595,5408167860716448,5408684428107176,5409849367090568,5410632742040879,5411331443175255,5412454472585029,5414566165245797,5415609710834006,5415695371666931,5419089032931253,5419410149086480,5421212445663696,5425776155703968,5426094979776865,5426200656191416,5426343929555151,5426776912686778,5430164168254949,5430190436911117,5431006377098396,5432406524156108,5433304691996431,5435448562589939,5435896905179181,5440154526785082,5441547898208015,5442550985028476,5443519792343783,5444690183081885,5450650932615338,5455725933689601,5458279931832152,5465378018390073,5465468293521107,5469790646236118,5470523383371739,5470969344336539,5475821882378207,5476748191248660,5477177690474931,5477215546181338,5482250465546522,5482349529630549,5484161477936466,5489284312856062,5489369658364069,5490569127868792,5490578216302488,5490912405243552,5491063328575234,5492060493233102,5493319803023083,5493870604473986,5494363692102912,5494461157119817,5495499148937672,5503103710464614,5503513389639664,5504970710328213,5507615794296947,5508503680735494,5510245439146833,5517653225284855,5521521718617004,5525271231853414,5525287503688276,5525784499514800,5531925548111268,5536775761553566,5543069752424250,5544364757593219,5544889822531729,5545802799820933,5551558523406896,5554380825386759,5557919753549640,5559553479542169,5560825696398560,5564194416776227,5565788618996121,5570645491117548,5571611222021582,5573849240853901,5575596617449768,5577945889491415,5579977210132164,5592415623299140,5593426453232836,5593438101378020,5594762061635643,5595805533420086,5601051424903560,5602700450924399,5603538322319793,5608980121089557,5611751716804501,5611829232056058,5613862108602356,5614909593083489,5620948070768841,5621064494334548,5624980176214575,5626090376879123,5630820724993122,5631339882742194,5637312677638017,5642756368786070,5646619733332345,5649080003160590,5650780390758170,5653701086020480,5653796862203418,5656741558549497,5658725183825313,5658909534123937,5663092617828839,5663224746690969,5663385121189069,5667246937392321,5669207929458084,5671169627021226,5672283937076147,5674772751954606,5674881982886485,5678334281220081,5680063203437155,5681070425108334,5681381324642587,5682063334177189,5684172435072091,5685175185189040,5685769039380624,5697986357589265,5699319238678867,5710195316669727,5710364792755140,5712513634730123,5714049549411876,5716073879150693,5717070125530008,5717707514404603,5718933939076632,5719318350374149,5722219703153811,5722341925100693,5732397687710195,5737673156168293,5738107664095562,5740477961003848,5743071426668400,5743439881123553,5746087812806105,5757334188672719,5759061891073762,5759605430401092,5760917932621246,5762846753375421,5768847482599071,5775786807466214,5778465954807704,5779727192598359,5782454578043069,5784207630429531,5787478509200132,5795310516601245,5796008702306245,5797972692335571,5798138329907864,5799653338299710,5812168700056249,5813405909746709,5814659226632216,5815038103915452,5815769481430106,5816254259189301,5817139504392687,5818962864882161,5819777108642550,5820855454510934,5828565941868585,5832544070159164,5838532420889856,5842761102579491,5844299901048343,5846508833190611,5848249740129972,5852387775678533,5854217826149760,5854563651692443,5854937355756548,5855201829880411,5856022939536906,5858993021903933,5870645665739004,5871947947418314,5873135456761229,5876393418760803,5876736261749158,5877001606837482,5879985416123329,5882204502376649,5883561342053177,5885444131631387,5891024028896298,5894685367317522,5898490390630341,5899953571983439,5902538428609386,5903023130181011,5903484378223350,5903666264650934,5903694388453077,5905923307105350,5907421027818445,5907964555669659,5908300976016885,5908696090042283,5911342541254411,5913584686996270,5915859656997442,5915879684950182,5916931928833529,5919430186483428,5924117520799744,5924879546681533,5932662790863585,5935312555770245,5935661430493388,5936458244198872,5937817448042430,5947101870703205,5950972051903959,5951041949791953,5951726048170658,5959591833211920,5961453615564711,5972218700939363,5973250287141740,5973677832178283,5977031849078959,5980413028217525,5980915072408561,5986049797838103,5990178361033769,5990570628261118,5991408956923883,6001206595929325,6002276243709531,6006751473613523,6006897308518556,6007168673792231,6008701405300969,6011214467146689,6016555602340361,6019980584840889,6021343266087840,6022056684496736,6023708485790729,6027671084996298,6028501758004444,6029374529344504,6030086882034018,6030811056861793,6032097611334280,6035955206980948,6036393255326821,6037875804029086,6043316342842623,6045838883225433,6046240594537014,6052608603431831,6053804686685524,6053958863313339,6055268730727559,6056807484550007,6058584581621317,6059626128614092,6060349809324070,6064665258673946,6068333876328613,6068413376064084,6070104051324693,6080103762457043,6080478088147904,6083108464119662,6085590822787717,6090956327042002,6092446030543778,6094860440146813,6095038202403332,6096229123772452,6097073203191647,6101803614000155,6103891026698555,6104688884599994,6107758160260988,6117554783717693,6120258253633213,6122246130680759,6124004526649147,6125907283269891,6128284886205816,6130127235214495,6132418324607306,6132924004245246,6137419131615699,6137499190958511,6138942623995374,6139205116246240,6142105213155394,6142558454313357,6142688368084569,6142913125046520,6143696702746869,6144042650109511,6144095475272536,6146897441024279,6148741765621578,6148853279972165,6150235479601000,6157729055897116,6158322788243107,6160835787414693,6164759784469239,6167018513092476,6167607991565253,6171707826744820,6171923593198502,6174144459043731,6177490881631784,6177692002705307,6180599532722384,6180617553423208,6181251707047392,6182491554240294,6185543954775815,6190932940940803,6192358086666249,6194877677308546,6195005368075242,6198857031919734,6200081358996517,6202082397019416,6205024632916134,6207302580763824,6207378542225127,6211423761058583,6215284878845774,6215440151333948,6216154454451807,6218233647565127,6218512470330013,6220604711061324,6227071256308643,6231011149213503,6233498546819624,6233949281800277,6236798857143890,6241164988469477,6242104415956598,6243407719027967,6244874563473516,6245142031174040,6246083992156972,6246838555534920,6248681770169460,6249074081912395,6249533555014967,6249758679502616,6250557402527662,6251868356478317,6256321719332215,6259204915009277,6259490107650645,6261686455216337,6264775741996340,6268090550897346,6270106477308228,6270623030455246,6274969421000503,6279802006819506,6280006843403268,6281149158892909,6287214058061018,6288468968814880,6289720240431181,6296712520206538,6296961749764111,6299817571175342,6300447716613718,6304110312835221,6304297838290460,6306042178881559,6307115079760207,6312245698368186,6322233760419356,6323173079753451,6324812402453371,6332989569253355,6335023550154225,6340198968767374,6341635725848363,6349550838587158,6351329315387062,6353191312166053,6354692547077099,6357396963255601,6358389478584958,6359135369542064,6368768790664617,6371090203570186,6372329995436972,6373927709843793,6374693910197545,6379348598089064,6381058024466487,6381541745622015,6383412722218595,6385800661078082,6388937996347478,6390863793314128,6393248753656632,6400930699922041,6407210526643342,6408730838629478,6417566655445436,6423382525818443,6425194770628412,6425609470770570,6436083078959781,6436814879261326,6437833183669176,6442916426075876,6445879733828998,6446194468328979,6454320146259766,6454656458940756,6458237633544796,6458546881927714,6459246249643680,6462957099049843,6466065562806689,6469735836499302,6470372839856936,6471689417982028,6475189364234877,6481335505362132,6482532068799143,6489722108699832,6490299073029283,6491310187238988,6494216993344366,6499734594021057,6503186772282900,6504859169508928,6507830449842499,6510975929277435,6512429809706119,6516226128433302,6517101056822953,6517695732739152,6523391982496272,6534435207304569,6546595430555691,6546752598032205,6549492551082113,6551592776053236,6551734714654604,6558599467268843,6559912537272461,6564596315827620,6565025138801153,6568196183285198,6571097539012216,6578237361036784,6578503095620162,6580538367081886,6581567286410558,6583498920293870,6584870489517466,6585680325396501,6587654701564589,6595708386553174,6597195012498438,6603273865988070,6603801197985057,6608001737567202,6610618463004567,6616149821789620,6618251453481948,6618435915029628,6618676721046335,6621057137514784,6621510191241835,6622760768363465,6623916460399609,6624459779043431,6627687708278604,6627783782930560,6630093742634040,6633360677360828,6633666298104507,6633679848040650,6638722800576445,6641201043777485,6642050646677695,6643521507427133,6645310761621776,6645793620729168,6647201893197819,6648932781635955,6662203303202718,6664034784936039,6668468822319197,6669190164030483,6669210952944500,6674364905825227,6678173806211118,6678617623247346,6682614209582006,6684865188797781,6688488067507556,6692574202860978,6699342662745446,6699601051457480,6699624106317412,6704989683409671,6707775008358828,6708073103705065,6712177752369021,6712531926902947,6714278367622692,6720191931854899,6721985110519865,6724452753537983,6724547078953919,6728831533279314,6729387689420763,6736575983773817,6739415899010945,6739727103059450,6740158910360226,6740782983077394,6741215022206694,6741585788244645,6742696713154438,6743224373710056,6745753170262651,6747208404406959,6747763101536606,6748010265803706,6749753901939374,6752595765235073,6754613815657166,6755291783706242,6758210091802458,6759785636657525,6760588036724092,6771281046319400,6776659810263253,6778747192742624,6782379660111174,6785714707329795,6788002061659117,6788203480253865,6788800055371110,6793843162054529,6794299705676629,6798835098061557,6801783177540135,6803312883106461,6804076005396559,6805314972544938,6810553630647669,6818759912474922,6820466834405823,6820988811787355,6823062398472026,6826362904176932,6827299283549200,6828195113056916,6828673134192974,6830256824394694,6830268541445025,6832523371438991,6832757900847449,6834697416576859,6838813212939647,6839800914177389,6840422052000127,6844288668932623,6844336660738179,6845320561422543,6853445475844952,6855214185488087,6863430375872751,6867967645790541,6871074917650344,6871762569059759,6872105304806432,6876239502460282,6877362362192847,6878773843563416,6885815333874594,6888484602467342,6898143968669196,6900004638975216,6901304183540564,6905543811188124,6905914351227176,6906188072934282,6910834673790415,6911812457892129,6914055104334094,6919747802868531,6923486337221090,6923858050003624,6925667026147338,6931518999817042,6933453950416148,6938107292471175,6938223955509899,6939538382301357,6941024220883212,6942033581849643,6943458456583346,6945044492009436,6945905024717815,6946506722091422,6950938208624374,6958833224242820,6958904824641253,6961314590681632,6962254218994978,6965104736708795,6966317419189633,6967131174911621,6967523404032078,6968626913917078,6969420545456348,6969723087551023,6969879544962764,6972390154085602,6973244897917836,6976680325243925,6979025352783650,6979370520679168,6980494356161697,6983701355572818,6984882368689931,6988956838531038,6991429378659097,6993040182965693,6994857505801835,6995106444038232,7005617682351029,7008681951900339,7013532370262403,7016766698233684,7020516223480066,7021458253363648,7024829388289761,7028222229541558,7032222151023244,7033834244257850,7037699445900528,7038117440889701,7039791823564250,7041664067028645,7047751781972594,7051101474207745,7066853754241037,7068328396622979,7074680907709588,7078519684380004,7079136072514786,7079683617185872,7080593167563472,7080754326247108,7081754833089042,7088092836502881,7089736549891749,7090263369022454,7090827224293435,7098844983687608,7098947119329460,7103287349340715,7106775558992670,7107670335907433,7112480093601650,7115518871031565,7117602179959460,7117702213846209,7121896123546063,7122417539516003,7127491990243549,7131364697338399,7131708477987584,7132927597129919,7134741662059672,7138333125007121,7140267571474431,7142051916052761,7145928955462648,7151557370156782,7151630939274842,7152477932457527,7159009930103737,7159750340676197,7161951879191571,7162737726252938,7163383528300707,7163778957994361,7164516813412020,7165245447295611,7165802414192656,7174995651546188,7175045659768963,7179002153816766,7179903040144596,7180233253963581,7181910283448288,7182233643190269,7182765199375506,7184665213216156,7186047534415508,7191096990307504,7192853768347715,7193706102549630,7194390208322086,7194559521713885,7200246343648885,7203674420028153,7203819464442554,7205356147061987,7205440304046385,7208185024842025,7212278254469221,7213120783173544,7222827358840359,7224545604825492,7225295067327053,7228437063271890,7229216536358274,7229486634256081,7232125512735235,7235403866067319,7239097855225524,7242588962722849,7244564678730935,7244768482327881,7245566857344055,7245574171883060,7246915842148466,7248035515958267,7248740619006619,7250821157176171,7251981552147018,7253494878650207,7261781244132035,7262289934411894,7266821489213820,7273694576417704,7276326602211605,7277092712154010,7277675196574359,7278382313447813,7279500143289769,7285678364340995,7286552397490444,7289711990915466,7291166276647549,7291878183300562,7293014405240845,7293120299138230,7293749228355547,7296021783720685,7302877510804560,7304251828684959,7305136552146877,7308279153931179,7308326077425136,7309526818453637,7312457538294268,7316330078533123,7326575126577204,7328477651553143,7329499380916402,7329807274879697,7333784851848575,7334460645181314,7338010137201066,7344453486207090,7348071649723458,7351715416841733,7354010904401429,7355017730071395,7356265961127969,7358759012302734,7360258178944323,7363433267890641,7363914809349690,7368773239687673,7368990891984284,7369067702745463,7370610419112862,7372069375963896,7372949260070506,7378192097899948,7379423885191995,7382680312753356,7384234111099756,7386045769570324,7389238093282141,7390506088312346,7390710096067356,7391350761414346,7392869637529531,7398773968230003,7399651877743543,7403437559059845,7404428308705632,7407156702916619,7407505476310295,7408970363179738,7411992302004671,7412075044877324,7413103855739244,7416878995770443,7417889495517890,7417992892826458,7418488700207131,7424981804351790,7425782918083640,7426542400535424,7427800180129173,7428977237077501,7430512069482015,7432189412984237,7435984239692232,7436873913790602,7437474890639138,7441160207213155,7443107233720726,7444471858317499,7445843328639391,7447921740402688,7454883912305228,7457626872755122,7457676720105276,7458849252908765,7461417070760230,7462663569101078,7463631301411392,7464791273301861,7465698531247575,7465989092511044,7468955978108988,7470414970130956,7470691742535496,7472016146671178,7474020170149742,7475500532623013,7476427212005998,7476626636382557,7480445069076273,7486839313625142,7490128336689868,7492881688861731,7496125413626459,7496331600674721,7496586171914845,7497334675461455,7498030447626019,7498854721985035,7499741802632764,7501028258951772,7507614839065812,7507971309059323,7513140878034397,7515642971443426,7520995594935128,7522857939238615,7522972726042717,7523568386509127,7524079580225271,7524269576290377,7526100758240945,7531837667095752,7535413758699104,7541987648743096,7543114741067816,7547834066144263,7548479757390478,7549509942679840,7551894748287854,7558348891266390,7560368518128933,7562422547591358,7567376160342247,7570266295972391,7570701292061586,7573494827827009,7573960972444709,7574807836993530,7575973317107614,7577088488345910,7577833848718658,7578638579275105,7580092247778121,7583079410262922,7583396165831142,7583610161177745,7583764379769101,7590281297167973,7594145655959215,7596032551054600,7597146399455249,7597942637150096,7598973979549112,7607301189042080,7609709905585175,7610703887034738,7612217782008852,7612590563938043,7614314828758987,7621333387572882,7626611098574312,7632486697445093,7635361684630569,7635562158981132,7635781595842092,7637503540227229,7638726889694521,7652643882172024,7652746090759244,7654546417001702,7658477424630193,7658594690166121,7658756342775449,7659221163244052,7662918821795672,7662988662313759,7663381927516823,7669465419287648,7670573068797315,7670620641239355,7672952384552853,7681671824852632,7684983390580586,7686401160104927,7688048940346303,7689154854909634,7692157387739297,7692684970839720,7692715035163949,7692872154514462,7699490741028377,7700450128758357,7700977357734436,7701360910594079,7702583314023198,7702985130021974,7704411344416172,7708512355032444,7710242527125752,7713386097094125,7715596322079470,7716490464557575,7717433898725477,7719367164738556,7721077927518745,7721609917312402,7721926065964333,7724275439905728,7724711616367069,7727393378005013,7727688737658572,7728376014820122,7729310710317073,7733140741473168,7734200858227759,7736185792522281,7737696128793781,7737983217239889,7738013935898363,7738701740734266,7739863878221100,7741777943023382,7741858774779160,7744489504895257,7745446514374882,7748491852382750,7749831849631228,7755681710231848,7761354279867729,7762078342885466,7763953570263003,7764178055458929,7765228989282823,7766279931264675,7766386543812808,7767211022402663,7771590301023685,7774524311327627,7774873548808587,7775101063842022,7779960077213875,7782860521094284,7786006720140585,7786623092023665,7788620682842041,7789581952394575,7789614831618262,7790698030398764,7794053661517357,7797582785345941,7799750768370576,7800931705061909,7802574315480743,7804528105537226,7805530729586763,7806696954752845,7807627836703959,7809275090934984,7810562577473506,7810998696754018,7811145204113168,7812875292301653,7813351823453750,7815570269509758,7818433714447086,7818985241100641,7820811550237674,7826881780965015,7841588711354365,7852366441407035,7855477820333867,7858337898821330,7859038279032217,7860940256961100,7862242994456228,7862261310643205,7862674829720928,7868991532284921,7875449163603356,7876379670914445,7877526742599223,7880427676195188,7880565443755679,7884420955280740,7884811158802680,7885098411306234,7885848475582109,7887337325195822,7888074400901569,7888437547783278,7888500835141269,7888937940310086,7899000176906699,7900170751256211,7900621082983922,7905781741383824,7908426346953647,7913734655033026,7915002755515393,7916235800659173,7916680314419175,7929090546906102,7930649670973791,7931707661135896,7932664861586881,7936886244974173,7944913299381989,7945929831888496,7947631560228941,7950277937806623,7950990573236144,7952207776184888,7953795569892508,7954932398971022,7955707767338343,7957474102755475,7959145831679961,7961026550920941,7961836057698682,7965048882911994,7965254194340254,7965797077819465,7966607859360283,7968378065126981,7969662040440070,7969835594231926,7970712198889854,7976449267333913,7980381451760704,7980587376998310,7980735420462798,7985722197062386,7987424295175338,7990659870433573,7991803781551882,7995443309794354,7997135431578468,8004620727574476,8005398354408904,8006897017337402,8006976419849250,8011722895901690,8015488350182668,8020262137556052,8021771731265660,8022639767891247,8024303669159872,8027888930993228,8028030091531706,8031622641498868,8032268170160689,8036544756403250,8039577302774424,8042606792528101,8059143994487654,8059389868173903,8059450627794768,8065220473196449,8065582408992410,8066856250710691,8070786000530785,8071396031944763,8071865664728388,8074839622143824,8075309354229708,8076610463040794,8077418220136860,8082463447862295,8082820682426435,8089588938475723,8092520438536915,8095523873680122,8097390569526238,8101747032613392,8103902946642787,8105559582340911,8106928489710751,8109224611652256,8111613278325699,8111834837966998,8112104031271206,8114237531654532,8115533670214246,8118745605497599,8125559994293293,8127077118203350,8127670090034835,8129591252255075,8130207971385588,8133101547438502,8136369282029634,8142129094140152,8142634926688230,8146147122452530,8146423080936368,8147298843986804,8152440707511793,8153430278421622,8154879940866529,8155913468349843,8161574196354384,8167731037663294,8169890334648200,8170876711488279,8172486649475348,8173481093695052,8174511269651377,8177258178550692,8178398099602601,8180578302229082,8181586463868245,8183290120256302,8192542128082353,8194052295501546,8194969512324171,8201561228078010,8205287009351885,8213078648151372,8214059341854817,8215247147279808,8216440810041214,8217581100021235,8227083500433512,8234783421929082,8243069565420263,8246362229814725,8246571670186657,8246716772071880,8249298655984594,8250387434460343,8251262500438279,8253292038013840,8253509245362164,8255193598573136,8256150707384279,8262954804685181,8264880757955296,8269769863632137,8270990875667143,8271331264772547,8274176242117714,8274986624684933,8279296087284122,8279996819873290,8282488428172923,8284234491989688,8284461011307366,8289349890472704,8289498162240419,8290108606340090,8296059295412068,8302834672979549,8303400292309646,8307300282278026,8309633984147819,8313010571574396,8316237693009389,8318696076968435,8322561813637125,8324890577048813,8325237800434916,8325244982386600,8325938551251775,8326972902154084,8332190045511090,8333273920187213,8333295411468735,8336642144860625,8336794237476945,8340676844945415,8342359556228636,8343138042400877,8343422431629489,8348222101988490,8348990845348274,8349203373840720,8349929839053237,8350563913948977,8353187069342082,8355184882068744,8355216613777188,8357480319128064,8358858858741708,8359032487697274,8359937481115835,8361648235426638,8365483157606045,8369163766277845,8369781975534209,8369986014094300,8370512453717737,8372233568621885,8372656358571690,8372801112194483,8378780774433401,8384716609191558,8386972491027812,8390262752827048,8391964943990114,8391968117889949,8392149294258272,8393630583129955,8393768386118269,8394309279691674,8397902100674760,8401772412872195,8403981740338541,8407164258966016,8408375889672914,8408897871278743,8409167580578255,8411174366535457,8411553661563705,8415585315618209,8416272151484023,8416499164135253,8422456048756653,8425355410005479,8425746898298470,8430090272302554,8437064789335853,8438071844029923,8440361071453676,8440380356686069,8442951603563362,8444597753784738,8451164588916518,8452143133964194,8452575040883450,8452834615819362,8457432590283505,8457870432601145,8460876401008247,8462195561662212,8462356309409364,8462601346948942,8462779787380437,8463298727839656,8463870357489053,8463894151036866,8468076223787436,8469301843796323,8471378426717330,8471415745047933,8471563440277386,8472404382970836,8476431185538894,8477713132692627,8478129640091772,8478656700478130,8480147732306155,8492172722223897,8495387612202410,8498566054392581,8499229793648454,8506577707409417,8509132075144310,8512769287132428,8513163154527267,8513504634548185,8515344063519634,8515639951840653,8519080382417862,8520471745574237,8521019872666823,8522204087666054,8524148859687867,8526397555774454,8528661500257743,8529230741888138,8532559561743484,8532654039076026,8533878116076637,8533995416155386,8534972628296620,8535864863010513,8535971593214147,8536095899242944,8538998791228153,8541155088407770,8542383026666838,8542730575053594,8543114315775141,8544314610710198,8547220901137808,8547645173766124,8550168506511268,8552407797606671,8554633315621859,8555174066389539,8557956937889635,8558151645997922,8564419827076679,8569931984640589,8573037870741832,8574564019658606,8576579691621984,8580540441959762,8581367096989732,8581514741937545,8582261197731227,8584103263448365,8586129879796676,8588390118640027,8592127243488072,8598659143101208,8599129846364328,8601522207106423,8602281558256184,8606118628054373,8609444821815491,8609962853072397,8614121798757181,8617181526340432,8624540329478798,8624651084037382,8626391389132139,8629842463638108,8636146645526997,8637210920266035,8640904163379570,8642764308475012,8643781560678532,8647452929444976,8649648448925106,8650858010075363,8651780938129367,8654788098657391,8656720607859172,8662946654635648,8663480084138334,8668647436071212,8669031552930922,8676906171988104,8679183398969712,8681093783245916,8681657579012301,8681913385441783,8682204342697103,8684040140251633,8684097483512102,8684751768894594,8687233995374161,8689451052146568,8691127371410028,8694259890202010,8697654461669357,8699889133691465,8707543506731647,8708531679191487,8712178326749055,8712816602478712,8713167337349649,8716771680701038,8720400968274939,8721624366660549,8723528441052177,8724633171674147,8728082879135077,8729412146383037,8730722008863330,8734134339123262,8734939213517479,8737898110156975,8738848475519027,8742554330928741,8744943197261790,8746710561869625,8748523887526601,8750131088989198,8752084776243496,8752229489182462,8753449625405863,8756024741015291,8760543221800194,8764085725594982,8764086372954576,8765499574275666,8770160446142554,8770647022715746,8771763736993583,8774891003290900,8776098695299089,8781771689282057,8782517514351572,8784173941687753,8784230938553258,8784888250030492,8786064399236492,8788645315265601,8789521527155337,8800564096781294,8802869383602903,8804388443116965,8811449372278684,8813402029006119,8822012246032459,8824420613904824,8830429684254879,8832496384750559,8833617601200992,8833874796390384,8838258102521647,8839028706384124,8842110080684260,8845997843748149,8848441586602708,8851257701148635,8853468904397509,8861373802714789,8863519477739503,8864760219124956,8865357049212759,8867353885504610,8868704959256862,8869726068715730,8870411403952522,8870975675141120,8873178736038157,8873416249089902,8877414428990774,8877639105413482,8881792922329529,8884591107643972,8885940282302452,8888927845730161,8889459042870122,8893272613048649,8895434307439131,8895481679956001,8896675958607703,8899181735854770,8900925086486215,8902963558752899,8905988019435537,8906632086442921,8908910033277314,8909764344727962,8910696373745749,8914511413857104,8917771334029599,8920188523017581,8922546229199542,8923711240385159,8935766685372472,8936182321294398,8941687890115326,8946727890515135,8948253205690631,8950897215480549,8953024300985451,8953097697609406,8965975649138527,8970258327898599,8973117667284369,8974686868338460,8976689351398218,8977775722619601,8979052050754475,8979138566806786,8981576765955511,8984458105294828,8989003157868761,8992303867068716,8995450059777411,8995595104860000,8998229369170752,8998231894652099,8998333901341575,9003633319081270,9007405002778383,9007488636990326,9014303703878842,9017342455231486,9017775639960192,9017776523155640,9017891402481729,9020092873224846,9020173374348412,9020279343602461,9021516015590569,9021894799811309,9026119584500749,9026610997051785,9026963896191446,9032187371857715,9032210357540524,9034758804495639,9036597582387230,9037381687162545,9038550674961458,9039001997861868,9043118071475817,9045075196494312,9048587505711843,9051912387029910,9055987575748925,9056929643995395,9056936895811173,9057135080376354,9060898928960346,9062131144374189,9062482327476992,9064531090448266,9067993556866039,9069591997036415,9069803512872245,9073037543243418,9073542014929237,9074741782974562,9074896498908993,9074954160428210,9079318768294079,9079888527353695,9080889860839475,9084555404746480,9084826088867128,9086751601932227,9087302249170271,9087576855511302,9089480345230505,9090741520266450,9091629224023527,9091979750057327,9092694428107073,9094268438464734,9095103196939357,9105108221601351,9107447274695798,9108055709626815,9118451715638510,9125362263593744,9126355422536516,9129589011938145,9140764221167599,9141233415372106,9142373407161024,9144094536673321,9144763081997956,9145366708442528,9145865355932391,9146655579064944,9150924106375181,9153419337108811,9154695178495413,9154906598387783,9156606782616571,9157112220513543,9157375841856013,9158676476944194,9161053565831887,9161337887056687,9162668962659557,9163686286137704,9164560080137859,9164808695055580,9165398869818013,9167720671191036,9167897025495661,9169656716086699,9170930061820260,9173729001963953,9176741363031246,9178557764270561,9180092141515547,9180835981759675,9181392291811972,9181647265316049,9182988335016139,9183382094237528,9189377040357435,9191103445945706,9191895644403376,9192183509335590,9192998929861767,9193828975544017,9197217346928151,9197639212324748,9199102551691123,9203464725118485,9205300319657392,9211473363037424,9213354462894451,9216455003394783,9216804553823594,9218987363983060,9220053901174655,9220215058481045,9223158651063983,9224216334262965,9224473925896720,9230580606053228,9230781676144500,9232717679435813,9233097697027556,9235929505977478,9240086077502048,9240969247386165,9241853489961590,9244558133126668,9245305719984166,9247060266595808,9247406544769797,9249469378126723,9253137291868229,9257234088108504,9258873442401546,9259919463922290,9262046728311117,9263681010079025,9265832866076313,9268110165919737,9268857065896189,9269629584818881,9271020411926998,9275826854508675,9281037194537819,9282900582141549,9285141224306324,9286405859692622,9287514586971559,9287536154919442,9292441630319582,9294005080674515,9305483767431545,9306667536052907,9307808415233512,9310504704434237,9310534519872777,9310837736724043,9312102571474905,9312863640688829,9321928567260274,9328521019473374,9335430691435020,9336501470800389,9342185161395307,9342406668720703,9345184722377331,9346395652886793,9349870750958121,9351366438163042,9352141537262568,9353138111214170,9355129827150685,9355940792017453,9356671043680573,9358554525655764,9362733354659581,9364121803157784,9365839127076720,9369093634111987,9372297233789740,9375641216209833,9380627712808545,9380933549226423,9391486822118482,9394976106188444,9397328261168293,9403248687345795,9403765537033865,9404565557844940,9408921495622508,9410138343134520,9418107590281736,9419050885946372,9420051724802624,9422324913293330,9423486081818391,9424880153466631,9431002060282822,9431492779408494,9432374689589104,9432764163393778,9435750849137196,9435999454995538,9436467803114520,9438440967152456,9443709445789282,9449047384009255,9449340008831532,9449825490510575,9450124549058915,9450266223912705,9451923415826938,9453754181386129,9453971573089998,9455474915077424,9461773699588349,9473529131534515,9474415802799799,9474996141555628,9475829378737099,9475995871994700,9481386469401705,9483868523475839,9484142791155130,9484371784536628,9484645634751184,9484984344438553,9487829549383095,9488440660460028,9494545505111169,9495020829401274,9495968673212120,9497284116324424,9499991472364593,9500280517945270,9506279993556395,9508600366209138,9512690141526979,9517376601859417,9535833980442651,9535883884558145,9537366154450470,9538837225746487,9543648898717657,9547557360432895,9551446569230932,9551720811964044,9551725517957536,9562955436175690,9566613425099599,9566896437282927,9570293654144253,9571330378157840,9573924489710217,9575165150956720,9575447421008044,9575885834850839,9580433067657888,9580743881484339,9583968797309156,9584561427927733,9592438802141140,9595887445842995,9598138185689963,9601786400945688,9602902468421116,9606089004061910,9612716915528679,9615883008659902,9616163690573767,9618201274077735,9618606069248566,9624534057788017,9624758856235873,9627779818273370,9630291603264391,9635050901818979,9635200697180429,9635826108626528,9636075715404256,9637587220059630,9638553113056923,9639877956221422,9640256664119083,9640617546856002,9641362208707358,9642086587237218,9642337236891028,9643465351021225,9645470246174746,9654460153869349,9655820024254396,9657079142409314,9659932051785010,9662025225596157,9667241625101916,9667276386502943,9668769821075358,9670575633086786,9672577363633156,9677328620414229,9678605646922309,9680568650978196,9680945785313483,9681067738687093,9681267064784409,9684623279089548,9688158907192595,9692893983377486,9693354057581157,9696973072157205,9697428811713558,9698607611846425,9698807166117128,9703534916274044,9704279295656371,9709604996254756,9710300281092436,9713887926847430,9722172985687510,9725792147964753,9730102559873491,9731226214799060,9731503574801034,9732040155273334,9733007182191687,9734113097684625,9745464335501377,9754378080750969,9759300027118446,9760103873246112,9762030899670571,9765786817226776,9774084603677009,9775954892341561,9776966614037415,9780333903305516,9781403088681411,9781535106471005,9782799358625264,9787218473221709,9788457577027091,9790409908022705,9790881050593378,9791716416162907,9793594547272040,9794922532119317,9797573432707706,9799346924173133,9799889278686304,9800030295823990,9800643300496312,9801531586161304,9806805952939348,9806868721015896,9810257422545913,9810585298035378,9811253944348670,9812581143154385,9818968963003545,9827735758375406,9829825895615971,9830504103227308,9834719687524431,9837155339404081,9837735072016789,9839088699938530,9840344155855293,9842146939578796,9847076554159479,9849134001046702,9852441706161363,9855152778377622,9855206514878035,9858589834827721,9861618221073016,9863827557020603,9863849833214072,9867562002147561,9868175191633702,9870764755744667,9884359216488201,9885568741169094,9888383442614567,9889475178157085,9897712283656982,9905482398851907,9905509887050233,9906275973443018,9913889302549808,9914537510086498,9916047888280925,9921463461654856,9925919096295581,9927737657495008,9929573922212347,9929764483111561,9936648632787612,9936930189944836,9938343239429508,9938930592288659,9942546535848415,9943414292728350,9943562503792795,9943864011523125,9944926015830475,9949019217971450,9950089238873519,9953036062253257,9954313852436822,9956729180121161,9957485611486827,9959245907790498,9959649353025290,9962236941487274,9963611887803061,9965461136179694,9969612722104891,9971538903925806,9972659184707003,9977300414299533,9978079501560780,9979873792406303,9984981814399679,9987074046553413,9988183784680681,9989307766906661,9992012151567792,9992489863586669,9994065414699288,9994394125866496,9997181429474537,9999648366321789,10000031604968879,10001967542883816,10002844747369936,10004355354687945,10004753411179897,10004925495910091,10008184940193864,10008461597296659,10010370056943249,10012185312140615,10013671898596744,10015492885655362,10023685800384052,10024132264331194,10024272312740748,10024518507785908,10032518948661767,10032947249799590,10035990167276835,10037675774604732,10037809897743304,10038212235930891,10039680011817758,10040986745269183,10043143509796825,10059920635183403,10060828780412828,10063209359600748,10063600315370974,10065868826751899,10069449569085214,10071227734029562,10076125937720394,10077012618270702,10079618128399396,10079621948289706,10079780537607131,10083178186730993,10083835921342635,10084552716356418,10086482396608517,10087308692864238,10097389948237264,10099505447983235,10104690390397810,10114762120457964,10122602940701424,10122880171361730,10124513873425872,10126854974524407,10127093259012746,10130357815395880,10137337268168058,10141780637088783,10147088443674066,10153687203860503,10154032807160608,10154307535853042,10155542068394802,10157935620629841,10159191120083975,10160572363193486,10168672086600643,10171123087736692,10172862470119258,10175620188550308,10176491974309391,10179148177005597,10179832358163856,10180241838937158,10184847409912504,10185718177412770,10194722954029343,10196461247193047,10196534456267718,10196953099771532,10197930644589487,10198150880968885,10198464083483418,10199671620517651,10199695570402626,10206312343978520,10212901875202215,10213139479075307,10213463533232206,10216483252367678,10217695934166737,10224107621925080,10224341861439251,10225134693123796,10227034461812365,10228698111458386,10232055143615071,10240074124610978,10243488805517527,10246053140844231,10246709309375903,10250309444905037,10251928862092099,10255002992865388,10255076774065681,10263727029912731,10264525977886481,10266632870176478,10266820110531598,10268623122698602,10270163349933746,10271469102327032,10272466256560721,10275712382795572,10275743124733814,10276224552844118,10279338798178278,10279570843072650,10280390537061924,10281974844357620,10282227974464305,10282399988125373,10296558578829119,10297624439979852,10300473582041388,10302531287111450,10303678734793518,10304142630006743,10306665063854963,10306671241348195,10308330126619188,10309932015401873,10313330243851850,10316871801998077,10322210269356740,10322320701863881,10322521592618940,10324442106918300,10324994061564475,10327639436128444,10327807866799501,10327836487278240,10328062775184436,10331778067925764,10333141638123601,10334247661646447,10336137590649056,10336141908355459,10338920736989409,10338954267321910,10340440885802174,10343162122531277,10343961504376443,10344652626799903,10345027200202891,10345084432193915,10346321946888164,10347621019337155,10349133099508569,10351850845125227,10353863721850444,10359452484332996,10359698556732429,10360599392353768,10362411781950181,10363323817948865,10363856866900979,10364382029087166,10372061926860233,10372218357405989,10378769233943499,10378785729437771,10383366177443313,10389930038194229,10391227460972667,10392794184418963,10398456973956573,10400718449268838,10401822723036473,10402346001657631,10402496249924470,10405138061495149,10406767930735150,10410213171505987,10412731504017633,10414467140029313,10416000710440513,10416079388720589,10416168489938655,10418627906817111,10419717389280422,10420088526584073,10422572464663919,10423223119950014,10426807564427788,10427907045176189,10428445873198724,10429449856647102,10429527060520295,10435742830395507,10437768643316041,10438557857523653,10438647586807590,10440006848965726,10440487445875223,10440675814381911,10440838933113584,10442812714871140,10444872472907940,10444918775580154,10446053077534203,10447329823526441,10447915458986158,10449211795777491,10449478084214979,10452337317481683,10455776599349428,10464407743036647,10466519528405133,10467269725152768,10469454767792558,10470445288089220,10475593353115645,10476986481775175,10479056751944619,10481951551463793,10482250308230673,10482598498169765,10483468440563572,10483827694271696,10488747371854077,10490741508258984,10500385026427457,10500488552967312,10501260356111404,10501722215268520,10506658427947222,10511491526436323,10512322942799654,10516207559148761,10522280817607587,10523297170953308,10524197477213077,10526510768148749,10528288350222639,10536793015345856,10538317185124237,10543343012752730,10545121874221728,10548308070444431,10548369311321575,10551722700063187,10552704438057457,10563044468102777,10563948380463051,10564172672934646,10564465419174573,10569204159673697,10569936885011978,10570501131760442,10572007743658085,10573848693130061,10576973213646289,10578269597246868,10583112117834044,10584639348411680,10591610358127492,10594696458041961,10595652543794428,10595951863277111,10597827758570119,10600222809763305,10608578244765171,10614584966626439,10617981318070703,10620592192554759,10620798042888920,10620903315872377,10621082248937070,10621656190299780,10628643234912171,10628921785417252,10629062237330307,10632137486945645,10633505584587868,10635387052790493,10636120887409717,10637542099362928,10638061479519323,10646006703435010,10647477117223054,10649758462203594,10650028403614625,10651121348848499,10651345084987832,10654625803270603,10656235398052009,10658173942822862,10662504984834470,10666212884489150,10672573019071811,10674748481093869,10679689448876826,10682941113641784,10687323304253849,10687860359404343,10688559968805974,10688935894510913,10691866174146243,10694995464922729,10695927691488062,10696210215422820,10697244763415996,10697779432468828,10699195330093884,10701317947376546,10706304398299584,10706909043141392,10711714669403874,10718007421668166,10724752029096689,10726920750492941,10729128988836847,10731904283058389,10734679576996129,10737172643669774,10738624568526464,10739494197398891,10739801112151611,10745410459322373,10754362437493971,10754439973590718,10754612019663754,10754840975650305,10756057668895469,10760825923948907,10761458124736280,10766441205077631,10772046172473065,10774595446367799,10778630114435824,10778869901743880,10784226394558794,10788763405851951,10789995657189753,10791861050542069,10799170614170616,10802291531494117,10808364573742279,10818898059592134,10822530870864592,10824560278958441,10827594971106799,10829139547484424,10829185368558325,10830397172609159,10836112530778301,10838960805917388,10839803231136688,10840559858292177,10841253641469924,10842762916923867,10845347885148728,10846960440347385,10847253104607377,10848123266416937,10851869476873826,10852276831524456,10854428106437565,10857916072243305,10860164830890134,10861241406654155,10861464877346456,10862767928581833,10863865955661974,10869288228094102,10875559469012941,10882881345336643,10885189706501902,10885610118151807,10887641846422423,10887994298217623,10889635463689148,10889705602128169,10889922295232389,10891169429104356,10892582539342036,10894930198422418,10898755272190895,10899505158719250,10900258521549713,10907247415705018,10908847810397854,10912096677076413,10912468397273952,10913442039163653,10914509218958331,10917306751752594,10919154244168193,10921613838119163,10921844952698928,10923207650640427,10924903323766904,10925760073226515,10926914968716545,10928023009400926,10929092524133184,10929712481962051,10932202328627689,10936209061534667,10936661551264740,10948587907569371,10948651072438721,10951948612195804,10957392874098981,10960839231611790,10961324577373192,10962850107056207,10965247315180552,10967958009290469,10969273755543103,10972909371352109,10973254456737732,10973378500551496,10973954138229191,10974341394948959,10975117317621830,10984973432275198,10985120232295908,10987375778918246,10989202769093484,10990148085591047,10990409005986868,10991558669034873,10992487444525627,10993385229040135,10995462961190157,10997692366332970,10998901653014457,11001665093098228,11008808435120331,11010187031870645,11014427382218185,11015340076911785,11019805073365185,11022642525947194,11023763375301913,11024320739845292,11024882796236184,11026937591363346,11027888013301225,11029825398794377,11030107171117866,11033359423251282,11038025471456068,11042536781194006,11052146708659621,11054833551017746,11056592382175061,11059015179729846,11059724780690779,11060604597813501,11061697713450470,11062402375665545,11065372708374227,11067445870108559,11069851171800453,11077507793952323,11078922436292795,11080633936367688,11084732592274012,11086682643753664,11089805668676955,11090552698103596,11090658585225857,11093188409391605,11095383304270179,11097675961341227,11099369753166044,11099443876685639,11099551546210604,11101013986140276,11106506387649741,11107790185802013,11118512285647959,11118595812639787,11123159825527527,11127177251318180,11128665035036595,11129601052279134,11129859357638708,11135583018648230,11140065829460222,11140988948695005,11141140926208750,11145709417931599,11147089278236103,11147132552690535,11148812667899998,11150199869893669,11151387183049239,11151502397313142,11152017569182815,11154110766156613,11155371761778170,11156287884240722,11157295858466191,11158612187535409,11158629464450723,11159414148224323,11161634288723506,11164777782395177,11169430954279703,11170123366563371,11173782300694681,11175202072568888,11175565813291353,11175852637265303,11181798592805054,11181991100493978,11181994897247651,11182614329697619,11184082345585609,11189705813167959,11200002087496543,11204988889480187,11206266985990044,11206560578270874,11208382996235211,11208848071296690,11208883644682396,11209337141935661,11211007053218650,11212811284757912,11221071832453748,11221944462355990,11223651527838218,11226519618035312,11228541607320093,11240903893878375,11242771850103375,11246366304663746,11247749010895948,11248516680815322,11253108954825869,11254820718151050,11257541020758021,11259273102107656,11261264491503353,11265599260848364,11265748427379316,11266044641422248,11270555388667442,11273050277652437,11273495836304423,11275385001301449,11278181039608867,11280555107389759,11282775695475018,11285939002885039,11287600375177114,11287893566206082,11289098740261755,11290096082576279,11294033441021320,11294538894871341,11294773174907559,11296245208210814,11296470298526751,11300136534963911,11302401895117972,11305713023756983,11308533382477988,11314863622778589,11315258511556799,11318554733251064,11318702654300182,11319884533641300,11320460996284466,11327586528497295,11327788291086163,11327962158491035,11328275638112363,11330273278512837,11332552015667182,11332697629166595,11339380783297842,11340864658831256,11341499658993269,11345417090314925,11345675657880008,11345676553858105,11347392098829511,11350091204264207,11350409656754127,11351342558936179,11356129989349558,11357460555425130,11358086643159577,11363343901357001,11364648346737734,11364824056279735,11373180683878968,11375384258325108,11376187659958324,11383750361347134,11385168267308423,11389069542696819,11389085993650118,11390806798887033,11397120253984795,11401700519195899,11403781057496463,11407990305495344,11409131295056209,11409539605925674,11412389566727105,11412775300232418,11415931058237717,11417029279499404,11418837401792416,11419050457279467,11421908057100484,11422650008295200,11425460679750797,11425662918976551,11425674126078597,11426226096713572,11428009680185526,11428645865887160,11428726096079765,11438432952800869,11438749233694215,11438964462131562,11439459859045383,11441352104880750,11443654407917900,11449215149184077,11449443817155297,11451628260579985,11452383134434032,11452907797520228,11454513203644820,11456881578527630,11457346667741182,11459692638425608,11461197052190285,11462322406268747,11463847343590781,11467023328231824,11467461219160217,11471896734320266,11475621626604237,11478518643790981,11479308399516360,11480479501736039,11481525923581615,11481618683495992,11482045627548411,11484846169745779,11486735344192741,11487334506766987,11492165643845175,11495135828909763,11495940916242717,11496597527405109,11501735487559917,11502614114611772,11504102260549809,11506002168816672,11508511752461603,11511943811198664,11519358680969077,11520326123589592,11524872224376445,11528838411980010,11529868184218561,11530775986224529,11532404505521434,11540894335577353,11542654016596156,11544013273650036,11549882026188551,11552173788839264,11555345985992577,11557042562885581,11560241639867312,11563775874866474,11564144512113061,11565733501856956,11568498682605843,11568678400586201,11571509198989020,11577027148599435,11578506049932280,11579799804334255,11580137502987103,11581727638901896,11582679596601249,11583117656203215,11583507699894777,11585101265495681,11585602648201715,11591010700913996,11591047076552913,11597838643220140,11599758357243289,11602974466808884,11603600057077164,11606641527777687,11607744254139892,11611075089640896,11611748354430947,11612441214023420,11614076397936832,11617014574952198,11618524239247374,11618542975088094,11619486235348603,11620240919983985,11630642998651872,11630644392646970,11633366940305309,11635654976144153,11641472090021925,11647860207129349,11653989895623444,11654096880041734,11656889598074910,11657305088193972,11658290320353828,11658683525550614,11661261117256173,11664834793123518,11668903396015121,11669172662936110,11672731802674267,11673471530068312,11675090294053441,11675693720283985,11677165015385087,11678347472951403,11678549904666632,11682179864909511,11684720239124533,11686779979541908,11687293075071490,11688194680736119,11688237614883154,11692359880827672,11693102982874445,11695198210730881,11698756388262452,11705418166137340,11706304165327067,11708423865084678,11713690607980814,11715198530711638,11715865254416537,11719221642455460,11720785116417048,11720873374212079,11724825796758899,11729906547745977,11730215861616220,11730467845423531,11731164734577661,11732952721643258,11734135498068733,11734286258484747,11738267971902102,11741745751488248,11741795538258227,11742706162990999,11752793473682764,11753431031818860,11760283991541332,11760357200965436,11763018637241008,11764356908364626,11764874035248246,11765704631176088,11765888369402689,11766277124024897,11770706878359270,11774539519322805,11775224796384506,11775268638007780,11776764748856507,11776942132964186,11777247958775743,11779851101536854,11780837550785665,11784146238668811,11785019194973166,11785179977172715,11786257969749074,11788283962253789,11790027713938832,11794754868481503,11801008269183730,11805477304749933,11805878919584950,11806503727533719,11806924283192272,11807259505447243,11807414633626556,11807669581624359,11808079978291033,11808975572599296,11811131114446293,11811220919611763,11811523671191877,11812158072536409,11821385353119483,11823032149508223,11829589265980831,11830306135038023,11832905268834899,11837866263209531,11840295016246440,11840740382069149,11844495882926956,11847454270904827,11849673093092482,11851393982879753,11852586198115184,11853010088136902,11855549547891459,11863824190392357,11865542872711201,11865871168473280,11867361302652812,11867622238392053,11867650483762221,11868412894656304,11870483176868986,11873041640016411,11875203851983460,11894361955867873,11898179901887368,11902205768982124,11902610012478374,11905938593275593,11908023918868100,11909747036535415,11913133672980273,11913579970744326,11915166616058954,11915355711841884,11915998238827549,11916592149001280,11917437865006221,11919527016797167,11921035710134156,11921515414457723,11923308867485120,11924342814255502,11926548936220126,11926805367659349,11927305572643559,11933874408252082,11937995919116415,11939403507435534,11941423373859996,11942641559491017,11943720753021897,11944383058348300,11944816331130113,11945878175058106,11946983372457097,11947591791853532,11949017351281018,11949638025485827,11950260926652836,11950767340516423,11952970464557834,11954080001324195,11957777053959725,11958250094792465,11958326503192345,11958460182520420,11971151405717771,11971876905384523,11975100060815344,11977092281022112,11982172621189391,11983457002283111,11985901107764494,11988505539494082,11990977878901109,12002472308960452,12002640382918785,12003675199241448,12005848174188578,12006152178822785,12006522182584979,12008453650739367,12010943352799406,12015639579945326,12017040715051459,12017280196275791,12020813104002531,12021776657102177,12028404727423728,12031148585055714,12035410219539172,12038634615780325,12038984699485340,12039188241767432,12044509607056046,12044968098240593,12048218812219413,12048806622801494,12051854023871552,12058410162198406,12063913410743326,12064775457651800,12066617975736949,12068867551957576,12071821708133242,12074697710387413,12075311552203459,12077762649844883,12078782315854672,12082781112646069,12084574037871764,12085458622840725,12093768254366446,12095848659926241,12100943786761249,12103181888338504,12103342077182428,12107118790447412,12107570552158084,12110485781570692,12115465620881652,12116763440852785,12120271099670822,12120377787831548,12125020327675301,12126157816621216,12128081806219839,12130816680520314,12135336896340352,12138087342617106,12142933439996132,12151932314546584,12155534792479002,12157910100394424,12160896977780321,12161240480327489,12177544020169884,12179235809448305,12180040689184058,12180082671639635,12180585227923233,12186122156905100,12191783234987303,12192518732233721,12193021349236348,12194188433406465,12195477889298302,12200556590308310,12206961056205598,12209415880387974,12209801129892265,12210314949080074,12213487168281093,12216273376124566,12217037909052392,12226080030988795,12232375265126369,12232730423776242,12245236639338607,12247196809844638,12248146350462714,12252787846207710,12256052919408540,12258059120145898,12262311222789758,12263515421972286,12267705797264087,12270286861002009,12270437462627588,12270468054721839,12270555442661518,12272177606302533,12272459725983251,12278383271194356,12278465206346582,12283300256073245,12283401561200311,12287926682400907,12288007619457690,12291976925956833,12293363361122131,12296222835104371,12296820332233663,12303801642442928,12308285254704879,12309693411990179,12312452990243375,12313776804764473,12315202162866781,12315207049934046,12315740660505432,12317737462653854,12318619168163236,12318972117511665,12321231790044265,12324460369042616,12331039831738345,12331300696945968,12332506101262263,12333514223027768,12334476628613713,12334531906613812,12338305387707689,12339633893332240,12340480227036543,12340540257772691,12344352953082555,12345475198663391,12347889475047477,12347909851317459,12348984482613842,12351058834484070,12360123775491680,12365750013844755,12370584345785125,12370916479710467,12371561019916631,12371619531441513,12373603008943996,12376979801050250,12378093444154909,12382677140019637,12383499878769639,12385506900879288,12386587160860313,12388152182711242,12390527382979238,12394812233113833,12397727946590385,12397876891941263,12399457458248220,12399946676042065,12400047520221171,12403402985696867,12405443348799387,12411872215293379,12414844391454468,12416637915847691,12418785272430115,12419850567303984,12422291487048270,12423854238100384,12427118539066615,12431826184921182,12433655594115296,12438014718231534,12442226783163244,12447792032805379,12451756713951097,12453429222313446,12454152316786214,12456127570195279,12456974565702299,12459098291635081,12459682501675724,12461500353394548,12462037008975433,12466553139749819,12468960135907940,12474505023309244,12474737975133205,12475509972236592,12482618753698829,12483487650973637,12484042018246148,12484792688815182,12493121285865448,12493531949000940,12495098079276198,12496153179323050,12496980807002515,12502404720988619,12505394079431787,12507272727771134,12509925643507198,12516465480475702,12518321496058049,12520510172302125,12520676894792717,12520923198368458,12522185666784768,12524882028709030,12534442305927340,12537207717978996,12537660041547670,12544032455101983,12547299118310242,12548400145943534,12549910268086697,12549984233150919,12559911253268655,12561212829273378,12562621625488566,12564478154644261,12565472974205299,12567396522160703,12568269490941488,12574953991866005,12576978802157283,12577735179930810,12579381981407506,12581297128593136,12582417504397196,12588761464768613,12593140979984180,12595025894277088,12595568697579144,12598104253633453,12603845745843686,12606498330358134,12606901469342342,12612150643542099,12612529707466049,12615183521565762,12616328366097284,12616804915889120,12626861709059593,12627315009999764,12632687195472462,12632877370509020,12639293891627838,12639418814809649,12640167527828266,12640428361474198,12640818042674893,12640871580590329,12640969010574572,12643494240426710,12648154041204525,12649728621420009,12652136957477942,12652319841467466,12653336178984584,12663246977448881,12665311296655878,12669546391130781,12672602214310202,12673433235167362,12675636205599614,12677222041198131,12678598650222967,12686784777563923,12689491560869302,12689747806173574,12692097685841819,12694087866558340,12695944838331405,12696892670705054,12699131424983717,12701707285742221,12702962657957826,12704004077140774,12704873713509812,12706444340854109,12708456140034254,12708947544688479,12714535709904775,12715273171560431,12717959780809396,12725278628820408,12726377376546299,12726436436675201,12727033270826653,12727451946669105,12728089463396868,12728609040123221,12732289199228764,12732375258004085,12733418262925777,12738924440303227,12743533106187231,12744154603604224,12744582863031207,12753329851400984,12753765690440504,12755303856704861,12757066236294652,12763333410197074,12763622227660696,12765318712019179,12767461214123574,12771267826669947,12771271434832766,12771598737920523,12781473711050942,12783187525335089,12784227033504239,12784609326080007,12786352863464886,12791039593741032,12801400431375545,12801458013719616,12805335924853732,12807665721211120,12809522469790774,12809632657832569,12811342685032672,12812182344672176,12812219134042656,12815737553583106,12818427882691906,12819996417836695,12823282271321919,12829072084255416,12829476237406130,12830088505582363,12833367241791767,12834860563514747,12836268768934776,12839962036566577,12841765441041049,12844733648085249,12846385534139025,12846697038313617,12848207419401226,12849610059774631,12855844760930756,12856329358872922,12859899907121954,12862703186291722,12865701070922173,12868140593650867,12868775358248804,12875124642594600,12876334808558683,12878995927947374,12880367229968159,12881424980533677,12882980822794716,12885096273299911,12885114452454742,12887418415101226,12891360431882375,12897909619055754,12899091919482854,12900385538356311,12905429635634528,12905692293007666,12906788489234465,12908599797018073,12908918208484790,12914133462565182,12916112665929539,12916597819623486,12917606324274906,12918769262556623,12926924362852450,12927617411585993,12927643921060549,12930688332553359,12932353138648641,12933527428631618,12934033598192671,12934350686066194,12935326619280462,12938980103044533,12940588789639096,12946489524267653,12946566221188718,12947498840965122,12947522776480207,12948080613899642,12955628525207272,12955751803056504,12956105567109482,12962843219163460,12963051823146860,12965554585695816,12965962908737325,12966841617454724,12969038927449240,12974390471676060,12977226874213721,12979430248880345,12979490664124573,12981848516554289,12984262343738292,12985447175738467,12985493722134421,12986155313057080,12988371356512075,12992253276160962,12993874964553291,13006729868315951,13013340987113008,13024057432246161,13024381576306167,13025387612752500,13035980728818146,13036179358537986,13036645177838759,13036712601500114,13039280823665945,13045554401715049,13045600513120125,13052620397276151,13054092485386589,13054236155028234,13056240047123862,13064661100685731,13072501768493061,13072617923580734,13076878689179067,13078572400562073,13078916872324125,13080772514935469,13084504306404264,13086192967607976,13087887536529400,13089196076037122,13095065334602578,13098251820355349,13099158956896694,13100332410846032,13101953020852801,13104310576837531,13104339456644663,13105958370560896,13106875818164772,13111138396854003,13111577005851999,13112638257280506,13114710504214063,13121300725611891,13121909629776355,13122025064383559,13125923793919885,13127052312166869,13131340694012302,13136566433737611,13154101095403837,13154198718450725,13154555158306370,13161267336530233,13161415791524131,13164318985726929,13165906763126222,13167979789890447,13168110604938660,13169610591191174,13170738127259843,13171457864347215,13173346736287826,13174101013456127,13175983907317617,13178301051103108,13180513572315636,13181966685315968,13184920558769854,13185428376339990,13189380579282347,13189931555333206,13192157399149307,13198474033954862,13199022280418750,13199996563430952,13200108351974202,13201217534286755,13201807925580353,13204068426067383,13204462037481647,13207338579601475,13208555149000976,13210353545775075,13211066578415956,13211782471313872,13212061616770885,13212165395243922,13224389449907250,13226427284217852,13233545463558187,13234370539005730,13236634090362774,13238503442128074,13241104178731895,13241168864292142,13241450032861794,13244435867492390,13248655970715829,13254044124605936,13254407272590223,13257737390535834,13262295991057322,13265037473491891,13267000919019282,13267936932487740,13268996306113479,13270587658638306,13271241935162677,13275333794285672,13276579099396774,13277158900551235,13277894633414167,13278754087006669,13280579253065923,13282031195456371,13283754265526543,13291686689127392,13298750975340353,13299748738344826,13302554742173335,13302754854003883,13314547425279234,13315440480385562,13319948227125321,13320420872897251,13322363408500421,13334201253531043,13335658832145523,13335793951712856,13341251444506381,13341697171605447,13344119991216847,13347417735653001,13349762651012157,13350157202852563,13351999147017999,13353105338922322,13353560065022737,13354308078114141,13356738691919271,13357601360650186,13357777396369660,13359331313196874,13366826205821166,13368583862334403,13371096247956838,13372563355342461,13377783353267179,13381234858826658,13381451339846643,13381463615587834,13385015528950604,13386085962502095,13391317927982365,13392333467703344,13392648285436965,13394339089063032,13394551832655184,13395404051517109,13398670081004882,13400424231648754,13402693157045406,13411243783632080,13411581109556372,13412383321315676,13413384478433494,13414514599248518,13421468939724196,13421914072312530,13422513191685049,13423145336408826,13423985507124709,13425535532287600,13428472113899924,13432304326725595,13434089379448321,13441217894362634,13441764089885110,13442145389950277,13442492834263372,13446542370234933,13448612638765991,13449787085268158,13450297184222915,13452686859689485,13458666141057862,13460158878440690,13460707130413039,13466324773783696,13466915673127210,13472766006472691,13475163848403231,13479031376600774,13479722850761988,13480475246113038,13483935819663029,13485627420255013,13489343668600089,13491793275562421,13492922551522469,13494212405375710,13495705064166062,13496578331102873,13498115501938661,13498151694534672,13501680776202759,13502294894809469,13502956667923325,13505729133720187,13508489645714817,13509178757772730,13511175763453019,13516547793277979,13517096887074591,13517706451256090,13519875476747795,13521772043996142,13526044181112873,13532220158545162,13534796579194791,13536760109792498,13544313444890724,13545457425167339,13546188892694360,13546292946364632,13550385209337680,13550826895184846,13552576650306647,13560284735647415,13565333800457376,13566103316134218,13568144225858544,13570582888476349,13572007215372631,13572474456641402,13573232562684901,13573812546471498,13577556238494057,13579292169199361,13581522598681686,13584670639804403,13588669826245287,13589817490954814,13593552113285088,13594750552645450,13597147770176233,13597925645597752,13599350900514499,13600455937242955,13603584733156082,13604060080584622,13606027345535991,13616619781513376,13617631557607803,13618539821196872,13621326447410048,13621397819727220,13624051916065155,13630191002096322,13633620941174365,13634793775983156,13639851440986869,13640894707400926,13641546138989675,13641606518127186,13646923852005472,13649712080213198,13650051593032688,13650210377673533,13651091819607497,13651608859118419,13654427643263871,13655297823287389,13656122678588204,13658284418429714,13660502914513022,13669670567123455,13678455803703467,13679106791368898,13680384031643874,13682988765515845,13683384323434122,13684209880729452,13685822158536416,13689486854312196,13692915679570837,13693027758544966,13697107073013916,13697542413338103,13698474516655449,13698940649521784,13699244375297918,13700132194249732,13700395889419752,13701739894308740,13702229819892376,13704275954624893,13704352361096222,13706325468204188,13706670591280801,13707739578231116,13709847705415370,13711378433916464,13713126833679041,13714567785435526,13716137377044165,13725507831112260,13727719554216964,13732875764071009,13733715420630549,13741167003840776,13741640976152344,13747934773389159,13748019953915162,13751009984842370,13751355444615875,13752515134941761,13752528537770243,13754252433328047,13758183684987635,13761196487629879,13761510571679661,13763495093063765,13763737248840023,13768364341578355,13769064518094184,13769616521296557,13769717386191937,13772233025470893,13775099387736730,13777389667865043,13779193834002005,13779343273469876,13782374358651901,13783090634865873,13784328184108875,13788511193264457,13792876019743227,13796714897651986,13797271737130311,13806888829912490,13810189191668783,13812724327885179,13812787107447014,13817281563439503,13819216056468458,13830139162136642,13834956579150593,13836967928987258,13837797409998221,13839575757828409,13841506126069216,13844723334748155,13851419674057285,13851804964013356,13852009236228299,13855148698755421,13860370948717274,13860591920719937,13863190387760047,13863369609343673,13866281554758808,13869499215642030,13872276184090322,13880535898359667,13883742410074464,13885432559815038,13885655208775719,13888286109395766,13889067944081094,13892103020384430,13892745650858214,13893729230139344,13894176996423815,13899539385676851,13899662982568281,13900800882545442,13903640450103285,13912471423586370,13915479520055915,13915571727313717,13915751473207815,13924477533272165,13927268834765068,13927696780643085,13930075501042467,13931574119523082,13932773453065067,13934376089480231,13939640716669681,13943906738910314,13944345475258344,13944744739592850,13946019636961185,13947359127055804,13948067910027087,13948733294751772,13948849305450763,13950003362814608,13951639188915106,13952318012300341,13954241713643000,13957499193946800,13957891625399498,13962475538041667,13963970693218754,13965952880287033,13965992746518202,13968491336408470,13969065894497400,13973873959866261,13973975217502633,13977253714955139,13980089127547653,13980251368951520,13982564979542470,13988336481457298,13988342852603836,13988537842078882,13990556415047062,13994731628884224,13995788227041302,13996482893584239,13999899007027615,14003981992485830,14008782205566643,14010800531316406,14011910995017831,14012374992832944,14013217052263591,14014139243194211,14014245139738092,14015507053297472,14020084142502079,14020512831650303,14021767660954154,14022387662308266,14024048892876207,14028210972184975,14028413705759475,14028827379125338,14031728644121479,14032607565016799,14033262874705267,14036809823113558,14039082912681460,14040011506200800,14041235400538814,14043155616226056,14050877619339410,14053671836796726,14054606050486682,14058900062976140,14059703515141024,14066003779541480,14068014186041757,14068336590289394,14068514727840577,14070452446274375,14073196557139903,14077628043807881,14078263357677895,14079413477735954,14080066225503973,14082060161725832,14082924359301487,14086400253454584,14089660453835508,14093701509746096,14093782787475587,14097413515224558,14098134531900237,14100156126841218,14104609562657279,14105474512409513,14108295823070985,14110176260429603,14110846566699118,14111683252929542,14111754638843301,14113954118968165,14115700381311298,14116084203345559,14116262159822071,14116757201838538,14118499555389589,14126627189476844,14129104187791374,14130408476214262,14134252052809090,14136333045989523,14140326674960210,14141721173215486,14142180399257488,14144055360914058,14144286736225168,14147641612048676,14148463071587919,14153016738442982,14154977816991154,14155975414531428,14157654079691198,14159319817299730,14164161800878394,14166972826657757,14167756848708160,14168406404233927,14172509973561101,14175692216668231,14177050809138713,14178906646157311,14182196956346963,14182395945348617,14182522370557503,14182748633123194,14183668773352145,14187963108760817,14188374719646123,14194895524216878,14195241543657573,14199253731869253,14200690083582668,14206687970696716,14209853920811090,14210270522420558,14215169574069725,14215487446995196,14216235072155258,14216815637469021,14221732530161551,14223983097652588,14224068397405677,14226640214180071,14228466984618789,14228747898918402,14232996386333408,14233668407191507,14240099294539079,14240931409922495,14241368478491777,14241836658261553,14245988562300971,14249502033018713,14249687565938747,14250698500890187,14252150350133713,14253371408889777,14257631509009617,14258964967231840,14262886155232322,14265027566645368,14267007162424724,14269635702889801,14270247164026926,14271254001084489,14272184634180062,14274483320198377,14274835544974226,14282336913974875,14284383355599952,14286491830383354,14288578362756140,14291469615410543,14292177888930623,14292472000295936,14295837452356585,14298173613618098,14301257753026067,14306183975145525,14306645039357579,14310015895186965,14312644574968166,14313406026871827,14314489483212131,14316275813435798,14318196470777373,14324046111985296,14324162382494344,14327796466197481,14328346609794800,14330165162804822,14331141876120768,14334872702153449,14335504292083940,14341688237268043,14348901888548568,14349694944734589,14354691446106556,14355465370177113,14356635961829550,14358501668762854,14358712927579207,14361182737613332,14367338647715234,14371667519001998,14373358653447165,14377687185916087,14382653933328395,14389328610898075,14394881294681167,14395773024997894,14396743849723041,14403258144398461,14412375459733818,14417058048931154,14419304139943345,14419408012320654,14419543889278265,14419921321665188,14420517295463922,14421526836577881,14422482515005653,14424431392247406,14425089420361947,14428672766558377,14428963789076396,14430041824596695,14431778710317696,14433829777128924,14434491585844546,14441663522114382,14447076836883551,14452272957197920,14452953847840233,14453826393896267,14457911302704022,14458414754313519,14459032874392331,14460517698166413,14462888447275103,14463327792076139,14466639418787131,14473045986857112,14473621415900765,14474428436715615,14484126976680776,14486000255831641,14486482190989317,14486688729309047,14490346112381667,14495252825099916,14499310299171024,14502523747605405,14503354640357478,14503374543528179,14503421058685682,14506805831023499,14518760367352883,14520484026405910,14520523492819317,14522374602858312,14529743691651008,14530895847000131,14532022272038796,14533166699323342,14533293345882871,14533881753163408,14533912823231087,14540918644501586,14541038147489782,14544565705546702,14545119728791898,14545143474688100,14546173469934987,14546950089114982,14549609943559194,14550595762050225,14552911878947037,14556533343840630,14559345024889908,14572798606348625,14577681860555800,14584989944454173,14586151667610008,14588926759145188,14591042865966381,14594358540722423,14597249351408083,14604151534357376,14605286985819094,14606919734830311,14607574529846282,14609506155169308,14611213899966353,14612303110337878,14615308468487258,14616719913389540,14622201375555281,14624808109756912,14627170591637528,14629700307326754,14630990679638089,14631058148494236,14631592206687850,14634273551714656,14642640937827937,14643031895991669,14644503421772784,14649557850029947,14651923318081732,14653944011582470,14656311355977165,14656739959494770,14657428090327995,14657604036440419,14660498729179366,14661525238412521,14661571640483758,14665151964517830,14667818466333059,14667887619466478,14668685675655734,14669778228653280,14674786028678808,14679329027774886,14680624844186736,14682369744095231,14686694622392691,14689136417247410,14689336054562019,14689925418874652,14690256368300896,14693014696087872,14696133465591728,14697396774309339,14699312209892116,14710079784099482,14714972951681498,14717202699896960,14718104616195713,14718737202293924,14721121520888939,14723600578234977,14725344942979782,14727357539437919,14731094891037597,14735849798460939,14745825891890849,14748981659235018,14753483946970413,14755563140273075,14755965578235618,14762529320475548,14763159151224600,14764626632313599,14767178168674763,14773705053327044,14774424971091441,14775120243637151,14776126161075338,14777180456645831,14778438956721458,14782806494910422,14784179132609967,14788298711427135,14789280323954198,14792259226342467,14793729824905262,14795588947820074,14796095030944430,14802524498404394,14802871040723160,14805119697076881,14808357837092866,14812335544631245,14814818422854695,14814866777217318,14816837023868332,14817193876879236,14818468476875375,14819019180401048,14819207660612212,14821775114466739,14822972850892928,14825309849899806,14831940328373824,14832649404008041,14832874204975299,14833648039540838,14838200055945191,14841572903295226,14843228258391975,14844521188313039,14847956454072103,14848728342207420,14862317369956557,14867172379036342,14870547288257970,14870966209589309,14872596463440126,14875244788896295,14875381698615063,14877010452958052,14878500342798560,14880698080993082,14883492855733875,14884715585223534,14885001023521200,14885277650679824,14885514754372569,14886553319298264,14886760885556864,14890608256545870,14891249907937290,14894841507741510,14896102249534025,14896432925297083,14899776943325306,14901170136723141,14912124553248599,14912527776416275,14914637529083995,14923768734921150,14927475356531675,14931070622748085,14932128317822464,14935912020884502,14936488056973006,14936652050795629,14939491797730666,14939629686310269,14940182819303564,14944948556154171,14945635561516349,14946184679427012,14946461852662808,14948084549356910,14948446551537791,14949218635729694,14950087955881572,14952347763435515,14955259859229041,14959164006691718,14959879541079484,14961785319983417,14965284707003980,14965423294444288,14969572759318726,14971749865458474,14972120173118520,14977645952278382,14980399363855563,14981979822436646,14982086157893159,14983194443165565,14988165815323633,14988877042506897,14993137347899829,14993931438103578,14995330188668762,14995498956830547,14996000077091154,14999867324523228,15002117713677743,15005212809692919,15006561064342947,15012111756331265,15014220467166698,15015143178733208,15015832834168182,15016747917580341,15017581694255009,15023369839042379,15025234321810668,15027872686689687,15027965624651774,15028626060466021,15029223449524697,15029421099140855,15030279194177507,15035193036474483,15035221909747542,15036546311204155,15039016426253545,15040592624783964,15045563863877798,15047851634879837,15049002718573923,15049206174008406,15053346072083521,15058256367816154,15061551128577232,15062104346670796,15064441595557983,15066002393956736,15066248594100780,15067340188537424,15068416282122043,15068737776024543,15074812809725285,15075955407954330,15076415543655217,15077707600112524,15081726781697934,15083561199479055,15093027538264168,15094135577988729,15094447720794002,15100586527842091,15100895887756881,15100923564154126,15102916238189824,15105535781519277,15113939304708511,15116572812194862,15119049194648737,15119364074336061,15124747624370744,15125038963644248,15125825668976374,15129837287101752,15132962157165167,15136429429441543,15137082733015950,15142340753501357,15142771204200772,15143222831944000,15144509730846110,15144908199448686,15144983031311469,15146266898753650,15148559483986296,15149385225563368,15151128984083655,15152827565413011,15154630818232126,15155535079044825,15160605890681429,15161531771304283,15162347554758104,15163502675118430,15163608007084548,15164714333985887,15166301729740851,15168611383865707,15169763864701641,15176552743308913,15180178091915825,15180542353613847,15180584383482208,15186748056330061,15188528526614937,15189413185888776,15193432864725218,15194881156695303,15198814250439862,15201662707583210,15205461449086049,15208520395012921,15208750046543455,15212200426217138,15213176306218798,15216511057457596,15223439233922134,15223468402959842,15227318255741609,15230987915584006,15231021927697063,15231273354612283,15232762341410261,15235610343655303,15235940695207259,15236962250830291,15237359393424484,15244577355919854,15245960594532078,15246816456164871,15247433843914559,15249106376606939,15249706293397504,15250808075404806,15250896481067742,15258347128439790,15259612081001691,15261325410662335,15264731568598059,15269511085038233,15270167427090968,15270685254846067,15271999905664798,15273539450147272,15275297064012400,15278958849739502,15281435895520542,15285891219756998,15286093688825701,15287593664509791,15289216934672906,15289862125703132,15290148594297788,15293123874907838,15293182038900352,15293645217732226,15298758428575449,15299580313640187,15302533346414874,15303593678697230,15304616612479818,15304832061714010,15304847733090522,15305964656672387,15306717541883905,15307253298108768,15311297318614703,15312452203678738,15314450403376168,15320795701669972,15323517768047447,15326286490335430,15327507236726425,15328510915961246,15329295210127478,15332234480025938,15334331246873691,15341118628483735,15345070383303245,15345125892343611,15347814684162793,15350044437706640,15353149026108168,15353232670298897,15353560384857675,15355335603796124,15356453151175679,15373930811061691,15377210030466896,15378726728075414,15379533653675249,15379705621247200,15383101065751713,15383429430446016,15389713624455313,15390882193918153,15397171895399395,15397714938008717,15399047611286521,15402636785814262,15407310719467201,15407689229789196,15408822912493961,15409962731524859,15413173941024438,15413515751683038,15416018244148923,15419969270171951,15420331379087852,15422639254858208,15423153342341016,15423849348726518,15427700916526085,15428180744186198,15430571383432346,15431617843729266,15433887937403854,15435323600513692,15437233971050551,15440395009514642,15450715294654043,15452122315683979,15452893020203867,15453212547751568,15455501413558991,15460176292435540,15461080320924835,15462583387869582,15462712554318219,15467422080353387,15469490756626048,15470882985899462,15482994955456858,15484173299282197,15484285765017800,15484872489499858,15485469980475937,15485540626374727,15486304467213949,15487350504789638,15489723346328738,15490588941327161,15491583096713568,15492393791023748,15493669981706537,15494125152072718,15500366938118891,15502621595004668,15503520406029992,15505519386765147,15505574369060822,15507002730562560,15507596083915355,15508961945139367,15509959425170992,15510353559011411,15514535598456693,15515999929535991,15520925704648893,15522061334524465,15522535607692803,15529140775061671,15530230344187268,15534018111390967,15535143794747681,15541941411191912,15544311106200778,15544548900805204,15545602100255398,15546317358086874,15548524218287149,15550480026583451,15557041324109368,15559289591149294,15560985952069394,15561564170785191,15561952874606530,15564933328360346,15566576834971175,15569147522045783,15569162342244587,15572863590946325,15576367136356714,15577133638656672,15578342327203108,15578855835529187,15583702740356037,15584544164633761,15585305674396933,15586021008360415,15586572984402845,15589664130788769,15591725629706008,15591835587885800,15595116715517183,15600022327961011,15605001570186520,15607295863535585,15607870388183703,15607922224614041,15609335762508274,15614292833802726,15618067512755640,15622298857392805,15624018957338891,15627058763799697,15627340304611938,15630540170717466,15632960102416693,15633826576330465,15638216907788849,15638858384127971,15642870227425833,15646298279506026,15646398545914329,15651638904781067,15652623828656547,15659367639512437,15663152139200473,15663328758094326,15663518386052835,15665466143502026,15668753861818902,15669831866387505,15678947651292970,15682293644901354,15682550662690976,15682651083715691,15684129061048451,15684714219805662,15685720052383568,15687701212595998,15689677439468810,15690787323220986,15695115813510797,15695286443123085,15700741802774887,15701006667851545,15702846415375903,15703609923212388,15703962508930357,15704863872433113,15708565750105863,15712661333238613,15713676720006796,15721982912314664,15724009739721668,15734864201421320,15735526608963708,15743385922200831,15747072816599513,15748859641437205,15754569858261273,15755103109741170,15755440794212771,15757615898310353,15757655394026279,15760850159052851,15762209549139285,15763329664260405,15763347842500031,15764824185348051,15765990586747192,15766276644999494,15768290846396154,15768790414358471,15771335490643202,15775430111926025,15778030936010985,15778073642134042,15779539730846437,15782503260567645,15783285537872233,15783457873235222,15783601313856694,15786952156025848,15788485549669269,15789578978089590,15789976943224909,15810883487535993,15812065825937453,15815973513115164,15816359902190124,15818396444990604,15822569260611075,15827668242360976,15827802457455271,15828816982020122,15830964443377814,15836027530140851,15840334179132579,15843352451900485,15843600775555820,15846238303690403,15850629388872569,15856599929346677,15858472897572714,15862471597714265,15863820783079073,15866236940451977,15867439138551076,15872237508205510,15872362195905201,15875441792071432,15875675232290550,15878101641545005,15878818841668229,15879020355620882,15879801226947188,15882817785917695,15884400676036182,15885288784193762,15886622651670213,15887607544869391,15890892365015163,15892366657661960,15892644929711368,15893531204246694,15895961844448293,15896204034306061,15897401962344850,15904895288630879,15906340120401315,15911275554215588,15911665623700279,15915302121110625,15920977380773424,15922176557778000,15927341566519732,15929095843806677,15931653898476584,15934933731048989,15936857080419564,15937966068964410,15939128572044103,15942294347954403,15944489120018696,15947799685112623,15950810185660122,15951245132791578,15954518713119826,15955148149270868,15962749062304179,15964603511322079,15966264651417527,15971851268005300,15979755963656160,15980573084169774,15988715093923408,15989521035310443,15992440720931469,15992587277784956,15995125981757130,15996510118296691,16003094114347164,16003140191180923,16004817238396021,16005398242861922,16011025198707647,16011459785052628,16012437984901399,16018234336000847,16018446377180066,16020878297532268,16027627388696968,16029458430644052,16030694108593481,16033495765775843,16034236792251998,16034525251814468,16034983761592996,16038301793476863,16041339465547053,16046531724225850,16049606674888102,16049891952869470,16053420407977591,16060832251885914,16061260687253150,16061782006846042,16061864710345917,16062107659438725,16062126410990904,16063267034774544,16068775512666918,16069046551508727,16069174976182272,16074486970716033,16078205716009594,16080740588460730,16083877262816687,16090383828959085,16093240548996996,16094586933607974,16095534305784346,16098091349807463,16098172275019426,16099819217713851,16100320419524573,16100367571904401,16101369009466497,16105015545889947,16108376586576329,16112103922334415,16116526388299685,16117917347854731,16120352726850564,16125621753305299,16126978195698598,16127193854760523,16133443983594261,16134101469312376,16140981467932346,16141034606926221,16144336314735217,16145343445599154,16149265475211648,16149410584199209,16150040833847011,16152783913026412,16158129834797633,16160771084575630,16161207778152920,16162431360492115,16172266596086346,16174694833891312,16175177075901135,16176635623269431,16178760128679429,16181654573567669,16184318280736106,16187175465801236,16188822750519283,16190571409547135,16191892773280294,16194604792338484,16195655847208731,16197234251528011,16198734954314842,16203717232904224,16205094133158569,16211596379781820,16214165140670292,16219147118653087,16220746069769206,16222085433520808,16225653430698144,16226175893934181,16232827850921026,16232879761043264,16233112123839300,16233654384438564,16235839769165041,16238168570863362,16240238424856187,16240762741306135,16243928721228007,16244975726182655,16250616875540489,16260035380139182,16260845001474054,16262693077001559,16263334721549532,16263780136827362,16264698063563820,16266712848564625,16270015344285096,16271083150992386,16280927247624124,16281915533551056,16285231420960465,16289174359879745,16289936972512243,16293042607710037,16299677703726115,16307595056946704,16307951982401723,16309137054392802,16310681306790082,16312561878704069,16312770958996932,16315681986373983,16317875420686187,16317916292886363,16318142278966024,16322691175661967,16322992301675770,16326980298830476,16327416205924614,16330104654201385,16333887903434801,16336789007686054,16339994308760050,16340878351385153,16341631078822258,16341765762990956,16344386984734169,16347036755860703,16347588955891873,16349055180152841,16349624325715789,16354850499974803,16356347789934648,16356612357153481,16359131609074861,16362066780194864,16365789041511778,16366474475380606,16367467787980037,16369009637282998,16369281391114209,16369945946899217,16371513179077382,16372297199748151,16373987141658826,16375988586939943,16376198685113602,16376801678126739,16377409889399924,16379852130875662,16383306457978105,16386085273604579,16389519573583307,16394940965979110,16396880383990556,16398154649330003,16398733126926655,16399995796956960,16400068126248119,16404482717922927,16411790303732795,16417759312392243,16419509766610091,16419901644544820,16422440467412931,16426184098291933,16428705781520359,16431504741295613,16432971637241688,16433327633528711,16435742390682375,16436934401638026,16437965595938221,16442543340364424,16445322365770696,16447014000822092,16448682699618749,16448694170228736,16448829682806061,16449874025980925,16450690918804765,16452048761762229,16457674212534286,16458193998413945,16460001490402451,16461454427883020,16462708921584182,16463091501301962,16465114761860846,16465120270600982,16465741257369090,16467984634116524,16471768032344118,16473606709399091,16482765139128458,16483944183054084,16492537636527687,16492753413249082,16495982586896225,16495989716457454,16497815380221164,16499668866369559,16511518960700260,16512946688413824,16514937777288269,16515317531532600,16515515620455784,16515763893503398,16517530335877295,16517537506431523,16520680878383534,16522040144005904,16528575556857601,16536209470153361,16545733416918453,16549193848756349,16551638786295881,16551950824707495,16553191501416196,16553580690041101,16555675508895642,16556142033974396,16557075823478085,16557622410594911,16560847038118314,16561729019896847,16568638052909005,16569637258340730,16573238078421961,16574236353677299,16578960500697920,16579013171395585,16582822217918014,16589279792859879,16589547714857710,16589699574162660,16591958210106118,16593504637950067,16594902459898391,16597325276377050,16605131007727492,16605918285966819,16606580432110149,16610275696347712,16613342366245440,16614318900004042,16615202652663010,16618755587989745,16620145335129622,16626806031696348,16627492809843158,16628850845685145,16629787832374700,16631291003094984,16639264165576493,16640125030088682,16640885497737107,16649312379995411,16650212270002998,16650283216078512,16651090716262974,16651922820531350,16652944391354801,16653981254978641,16654463028053744,16654924393162193,16660067927175777,16662173317527607,16664243102641776,16666335390036133,16680510934158810,16680561622616533,16681703184890456,16682373593295548,16682578612688855,16683370222392777,16685180422791776,16686094229531741,16689070891238240,16690146533382363,16690482654703766,16693110746762578,16695572956327954,16698836185067210,16701956823305074,16703748939565094,16705001871343701,16708313260115034,16708636667018243,16710176517292164,16710559192935968,16711961207359232,16715146526807170,16723897963678559,16724720235210872,16725294155137383,16727032133352391,16727688366022871,16728617677318426,16729903330833289,16730086424088046,16736716353986757,16737373768108148,16738231483083309,16739182735043548,16741586841667302,16747302145863252,16748052258655621,16753484567013429,16755872335309848,16759630605474370,16761517688376276,16762470025066864,16770317161224453,16781529885866521,16783509929261859,16784815745535597,16787458448627887,16788378238902865,16788943958964328,16790832050869615,16792514152922390,16794869894044860,16804435888556957,16804992154815880,16806577309208351,16810005023919202,16820470058192790,16823502750640122,16827645998999199,16830315094868687,16830808420468113,16834585049588482,16834728273804865,16834972450595855,16835383911593327,16837630758069802,16838337071218776,16843019140494565,16843470568018147,16845510952053197,16845624208838007,16850886838136612,16853500585157421,16854336811041325,16858777046733835,16862894270178946,16867020968629490,16867864576520806,16868617407655959,16868967869968917,16868995983462599,16870191809073980,16870410864242800,16871035110851631,16873285741528950,16873469111493195,16879575300669180,16880613203851258,16886381613931407,16887131664281014,16888158543374642,16890451337099671,16893613954731672,16895785673304080,16897929991993805,16899560454680533,16904808506504605,16906302534491199,16913880033664423,16915167008196857,16919853192670914,16923906246184599,16927073596682644,16932303526577817,16936593144852542,16938552604164274,16942918462021910,16943992912828536,16945631443389494,16946796236946721,16946891861601499,16947503499298974,16954247022723534,16956760996707076,16962140177483474,16965324067224038,16966208347117891,16966288341250307,16967956019390437,16972536001685219,16974928251104354,16974976960555336,16974996106586527,16976062143580028,16977273505567584,16978106126234654,16981100707531732,16983782183294117,16986912710655502,16987865244712954,16988076401714671,16989134716990479,16989387007759710,16993284480102555,16999277768258162,16999582099196194,17000582419359490,17001105496098528,17001625874486973,17004131992558114,17004653707426806,17008056242821339,17010630988250026,17011749486628658,17012043753575418,17012833537545574,17012961214609946,17014229777897844,17018029384954439,17019281824197645,17019567982933477,17020453662805895,17022002414706187,17023649823777761,17024720259097538,17027024912981538,17027708820603609,17027724390296374,17028905071969344,17033234045807732,17039633394164779,17041956186643831,17043911086147728,17044227183900592,17045020641718652,17047039786296372,17048296154567372,17053515690985016,17057095725127500,17057625560583053,17061147464856536,17065736283826551,17066204322105669,17066614080750842,17066890141401799,17069702342298005,17070031206981939,17072355840387059,17073899007528806,17079370707234233,17083995713147359,17086180968300670,17086729799181588,17092924242292896,17099823267028374,17104993056562977,17105192154942325,17109970886679733,17115609728681850,17115701285838058,17117697441697646,17123770859038573,17125687975796961,17126111370551570,17127731933707424,17128001588707458,17137453008591868,17138834595066375,17143215310364176,17148449573094945,17148520678822028,17149911306286158,17150277478457455,17156795393603011,17157898465093855,17158828067013858,17159011651238619,17164804932271093,17166238296990702,17169462933515083,17171938449805958,17172705597475016,17173738199873813,17179028413037838,17183821169135232,17184647258662518,17186046317572781,17187089371311171,17187953830759362,17189985997758617,17192344156604394,17196706052792205,17197787441563201,17207135836467323,17215506044805401,17219161189129714,17220298721334363,17220596644754506,17223793252771328,17226202100493419,17229146874971723,17229711692736590,17233175158773315,17246076311509922,17253960997681551,17254418065865558,17255087609386479,17255995767294112,17259835753342812,17259853567766215,17260765999229191,17264169344327606,17264493723089078,17266202911425676,17270142522772385,17270315441865085,17270538843510702,17271660415976271,17276987541026097,17279346919275792,17282090993143952,17283049372339150,17287308980519422,17291560172494026,17291831163747378,17291979671712321,17292310452468705,17293149261210524,17293672805669573,17294786701918124,17296137849004163,17296192463290079,17304784741854216,17308433563518540,17313632977406292,17316550266876212,17318692992816441,17318960805851472,17319678699176484,17320236078883475,17321015553228474,17321174101274260,17321666593584496,17323038726279631,17329308502917698,17329472456864413,17334589990132684,17334864272182912,17341517372421961,17350493188966563,17351099007094397,17353053843653375,17356450551100592,17361459937027549,17366561361017324,17366983669838823,17374030449048011,17374440012919698,17376228701452293,17378407166143928,17383067517360975,17384549204332200,17388171487087893,17390942323634254,17391219400155350,17391436242706241,17393048485135831,17395137850335402,17396392214119358,17396707198829202,17398282123693718,17400652202709747,17400913153559823,17402765624273936,17407246841014078,17407426140293546,17411561521038686,17413919734842125,17419751217564568,17420104524816546,17420485858384051,17420499051739414,17421399034054265,17422062281526649,17423105453982540,17424289896774390,17424603372802588,17425090194197669,17425614571955386,17429451938052494,17432341969660318,17434671597288182,17435530177003750,17436880586844845,17439323243822026,17440523469292889,17441789247813820,17444828676918251,17449218340958120,17453531113236656,17455682577560844,17459797692635710,17462899646868656,17466786275120017,17469533283184289,17469793035926815,17470441567829100,17470553723629372,17476597996427511,17476827515285484,17479085198776952,17479606979027524,17482162292356881,17484120239214373,17485776963997171,17486065626693618,17486966299030886,17486988765689599,17488816545423819,17492552714657461,17495168335254171,17496728290093656,17497098710311670,17499228377170813,17499405596516174,17500477193853245,17503587588672849,17509297579594984,17517159540441318,17523212990532497,17523973238515804,17527155984508354,17527974390318800,17541513425567569,17544483234277015,17549550257204532,17554939460224349,17557110727324424,17558580292594243,17559431629269263,17561308627872597,17561448044068445,17562043899076409,17564200744376749,17568398735638434,17572077476907682,17575183081748977,17575560135721806,17577145952506174,17578206520670377,17578443574662836,17578541772101901,17582264107806662,17582290334182417,17587104794159329,17593208073575946,17596039699851909,17599396608009249,17600395630340514,17600607133597719,17600742857183071,17602282637407016,17602421941387749,17608558726968620,17608993167118462,17609153518381219,17612331367634278,17612661692978454,17613143173683718,17617501881367841,17620186225453340,17622683372375560,17624682438741740,17634636486838908,17636045194545382,17636697018575467,17637276506512333,17638727474061409,17641686744036386,17644039113805944,17645312266322218,17648036628500448,17648654551195334,17649827707409978,17650070599794961,17653795308183431,17653847556747406,17654605406803051,17657497846454029,17659151263914508,17659159129272154,17659621059389953,17660743105223332,17661459152369712,17663870523590598,17669690965056750,17675621085369441,17676740506186425,17680247160773935,17683919063883369,17684983449309621,17687312381466046,17688285802217756,17695758922408170,17696080779036912,17697163249179507,17697682504000309,17701077865761339,17707842984156022,17709737359906993,17710801222146557,17712409569723362,17712893886367554,17714216772557464,17716382248321539,17720864955352360,17724374962463603,17725080861093763,17729422012525851,17732456419233492,17732883134864219,17735784358608079,17739008654014794,17741767011633464,17742935181641766,17745817163447253,17746048334435768,17746097465586084,17746510075374564,17747383804315050,17751474508572071,17754583581253314,17756105008438482,17759977925515375,17760075502845602,17762550181642638,17767687613087632,17767859597836083,17769865028645273,17770964005963097,17770981472959907,17776801117888552,17778432694523101,17780417672471981,17780633004635622,17781695742071988,17788814020175258,17794405366215460,17798330281681371,17800329108711822,17804753485051696,17805181966455568,17806610508162479,17807775056747143,17808423895668929,17810904352266449,17811294904961592,17812394543477172,17816589074280916,17820414995546733,17822270975275609,17822389231945555,17822754688428992,17824058019135158,17824692487924143,17825578992465786,17828557450318443,17829073738411995,17829961694570784,17837924490120715,17838730974038612,17839479752655995,17840541840864217,17843709229402181,17844481483540230,17848382043638116,17851847109708456,17853025423149005,17858621723903710,17859332251377907,17861897896064008,17862392392198693,17863284124973089,17864179561603861,17864592832666958,17868918610462163,17871331243165860,17873251325535856,17874140595461808,17875253398653485,17877847214982476,17881080524524475,17886201118232867,17889149793462925,17889210293831194,17892154693253238,17893885025675099,17896653342000979,17897530801963500,17899962937369458,17903487860911815,17903975998539248,17905797190497744,17905846546240170,17906626648911419,17918445343593208,17919141917693070,17922025141068374,17926997493031437,17927103791988031,17933163310113598,17937654095049111,17937781070124082,17939317258777532,17943850737738299,17948193446220343,17957869302232059,17960883452908748,17961387047243191,17965956572473636,17968648867149500,17970314287831337,17970920336945917,17981163734458850,17982550082813185,17983136277324499,17986809951266796,17988399353053093,17988976441880620,17992768183819834,17993406082650772,17994870382615510,17996626721764095,18001052491537957,18001307487480335,18001311042217802,18005492007967208,18005654000109885,18006237324341425,18006674318531347,18009042115164135,18014189215438894,18014657110806736,18020223752353121,18020974854298469,18021886555673319,18021977349239142,18025297591294553,18025990996334243,18026004208911747,18026232516319011,18028316562390501,18028401974332903,18028689821463165,18028750965578870,18032295415917214,18033529755426165,18034259808131599,18040783877464002,18041292302459796,18041417715473006,18042108482583522,18045296264870451,18047765377766084,18051621313730146,18055041696562277,18055087872299409,18055202394875922,18055587125968368,18056175313091286,18061732028717577,18063228282000881,18065517227719762,18068141364358640,18069471533986377,18073505561056499,18075555160804033,18076181993827292,18082663742446595,18089241107640661,18096458958758462,18097497168766291,18099088172925428,18100450631735810,18103384897215847,18103391723450706,18128418480848031,18129411267420255,18129647245646890,18131898498754493,18133139401742517,18133412567042816,18136432192167355,18136446741098044,18138184127031654,18138810838881859,18139979326512529,18143945517551573,18144683812945242,18148112805994599,18148249253291380,18162150874995819,18163443951512529,18167996487849658,18171316446408821,18171388681157994,18176398171978336,18179155755591764,18179306742222141,18192955804156397,18195325370888256,18196133156509510,18196224826654508,18198481066177520,18200162616918760,18200433284258315,18201109647268708,18202136593857712,18202307391092451,18202693116385381,18206082138173204,18210889187587826,18212687689930990,18214396067041321,18215366819351334,18215593970961402,18215688955701607,18218596306288915,18219379302426418,18219480778356908,18219492177590913,18219974968329184,18221275900037908,18223508196000123,18223532024009924,18223722170355650,18224739650203902,18229457516235802,18230262104505569,18231210235164500,18235829425519784,18239788102481133,18241422988672837,18244762343715926,18251366028776903,18257416541434878,18258026885319486,18258589056361348,18258704607599735,18264270499497472,18265102179546859,18275143099845195,18276178788453575,18277496942328603,18277532214379777,18286308776218058,18292647811673026,18306497644679138,18307189866355692,18308565966347727,18309896670692432,18310224044923199,18310260983673763,18311678202467591,18312531677561972,18312928450191138,18316793768442045,18318512805857755,18320199194413158,18320706645413560,18321247174629916,18325796935244911,18330283146493020,18330874810048231,18332434291015304,18336172353658611,18337191336507655,18339590117968112,18340192768781464,18345440651863510,18352607575179916,18352917019852258,18354768007590930,18354809741903140,18355594602361787,18358922153576498,18361245509159168,18362561943328805,18362962670225728,18366897023001689,18367494137613346,18367801753285595,18377045322936582,18379087767248948,18381374117454963,18387301731718687,18387581519957645,18389284618749271,18389507703197237,18390199754916829,18395298635755745,18396190684827929,18399039864999227,18400962357421119,18402740084326073,18403600751028920,18404059324034888,18404723916877195,18410807087565130,18410857770319952,18411318682491459,18412761413118797,18414602864713505,18417094802826718,18417594946606594,18423285819437783,18429692224833089,18434322404441043,18434895555056928,18434966080784818,18435861506577465,18436307626060546,18437428333588590,18438159643459969,18440518503779835,18441289041766013,18444487060015322,18444666364288446],"md5sum":"491c0a81b2cfb0188c0d3b46837c2f42","molecule":"dna"}],"version":0.4}] \ No newline at end of file diff --git a/tests/test-data/picklist/empty.csv b/tests/test-data/picklist/empty.csv new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/tests/test-data/picklist/empty.csv @@ -0,0 +1 @@ + diff --git a/tests/test-data/tax/47+63_x_gtdb-rs202.gather.csv b/tests/test-data/tax/47+63_x_gtdb-rs202.gather.csv index df9c2a14f6..ccbd4b1993 100644 --- a/tests/test-data/tax/47+63_x_gtdb-rs202.gather.csv +++ b/tests/test-data/tax/47+63_x_gtdb-rs202.gather.csv @@ -1,3 +1,3 @@ -intersect_bp,f_orig_query,f_match,f_unique_to_query,f_unique_weighted,average_abund,median_abund,std_abund,name,filename,md5,f_match_orig,unique_intersect_bp,gather_result_rank,remaining_bp,query_filename,query_name,query_md5,query_bp -5238000,0.6642150646715699,1.0,0.6642150646715699,0.6642150646715699,,,,"GCF_000021665.1 Shewanella baltica OS223 strain=OS223, ASM2166v1",/group/ctbrowngrp/gtdb/databases/ctb/gtdb-rs202.genomic.k31.sbt.zip,38729c6374925585db28916b82a6f513,1.0,5238000,0,2648000,,47+63,491c0a81,7886000 -5177000,0.6564798376870403,0.5114931427467645,0.3357849353284301,0.3357849353284301,,,,"GCF_000017325.1 Shewanella baltica OS185 strain=OS185, ASM1732v1",/group/ctbrowngrp/gtdb/databases/ctb/gtdb-rs202.genomic.k31.sbt.zip,09a08691ce52952152f0e866a59f6261,1.0,2648000,1,0,,47+63,491c0a81,7886000 +intersect_bp,f_orig_query,f_match,f_unique_to_query,f_unique_weighted,average_abund,median_abund,std_abund,name,filename,md5,f_match_orig,unique_intersect_bp,gather_result_rank,remaining_bp,query_filename,query_name,query_md5,query_bp,ksize,scaled +5238000,0.6642150646715699,1.0,0.6642150646715699,0.6642150646715699,,,,"GCF_000021665.1 Shewanella baltica OS223 strain=OS223, ASM2166v1",/group/ctbrowngrp/gtdb/databases/ctb/gtdb-rs202.genomic.k31.sbt.zip,38729c6374925585db28916b82a6f513,1.0,5238000,0,2648000,,47+63,491c0a81,7886000,31,1000 +5177000,0.6564798376870403,0.5114931427467645,0.3357849353284301,0.3357849353284301,,,,"GCF_000017325.1 Shewanella baltica OS185 strain=OS185, ASM1732v1",/group/ctbrowngrp/gtdb/databases/ctb/gtdb-rs202.genomic.k31.sbt.zip,09a08691ce52952152f0e866a59f6261,1.0,2648000,1,0,,47+63,491c0a81,7886000,31,1000 diff --git a/tests/test-data/tax/gtdb-tax-grep.sigs.zip b/tests/test-data/tax/gtdb-tax-grep.sigs.zip new file mode 100644 index 0000000000000000000000000000000000000000..32888337474af0b337235aac80c170d07b4dfb44 GIT binary patch literal 132319 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≺f>45j#y!dpGl^iNvVuAGps* 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
    bqm!yWe)9b9+aFeWTsAp@ei1 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 literal 0 HcmV?d00001 diff --git a/tests/test-data/tax/lemonade-MAG3.sig.gz b/tests/test-data/tax/lemonade-MAG3.sig.gz new file mode 100644 index 0000000000000000000000000000000000000000..60daa3149ab9d96307221ee268de317ea4041dad GIT binary patch literal 18917 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 E09S+qQ2+n{ literal 0 HcmV?d00001 diff --git a/tests/test-data/tax/lemonade-MAG3.x.gtdb.csv b/tests/test-data/tax/lemonade-MAG3.x.gtdb.csv new file mode 100644 index 0000000000..348904fd2c --- /dev/null +++ b/tests/test-data/tax/lemonade-MAG3.x.gtdb.csv @@ -0,0 +1,2 @@ +intersect_bp,f_orig_query,f_match,f_unique_to_query,f_unique_weighted,average_abund,median_abund,std_abund,filename,name,md5,f_match_orig,unique_intersect_bp,gather_result_rank,remaining_bp,query_filename,query_name,query_md5,query_bp,ksize,moltype,scaled,query_n_hashes,query_abundance,query_containment_ani,match_containment_ani,average_containment_ani,max_containment_ani,potential_false_negative,n_unique_weighted_found,sum_weighted_found,total_weighted_hashes +116000,0.053456221198156684,0.050347222222222224,0.053456221198156684,0.053456221198156684,,,,/Users/t/dev/sourmash/tests/test-data/tax/lemonade-MAG3.x.gtdb.matches.zip,"GCF_006265245.1 Prosthecochloris vibrioformis strain=DSM 260, ASM626524v1",e5698329948ebe9b7abe87f8ad81be74,0.050347222222222224,116000,0,0,MAG3.fasta,MAG3_1,8ecd0805,2170000,31,DNA,1000,2170,False,0.9098455716675529,0.9080886392176899,0.9089671054426214,0.9098455716675529,False,,116,2170 diff --git a/tests/test-data/tax/lemonade-MAG3.x.gtdb.matches.tax.csv b/tests/test-data/tax/lemonade-MAG3.x.gtdb.matches.tax.csv new file mode 100644 index 0000000000..99dfd7b3f6 --- /dev/null +++ b/tests/test-data/tax/lemonade-MAG3.x.gtdb.matches.tax.csv @@ -0,0 +1,4 @@ +ident,superkingdom,phylum,class,order,family,genus,species +GCF_003344365.2,d__Bacteria,p__Bacteroidota,c__Chlorobia,o__Chlorobiales,f__Chlorobiaceae,g__Prosthecochloris,s__Prosthecochloris ethylica +GCF_006265245.1,d__Bacteria,p__Bacteroidota,c__Chlorobia,o__Chlorobiales,f__Chlorobiaceae,g__Prosthecochloris,s__Prosthecochloris vibrioformis +GCF_002113825.1,d__Bacteria,p__Bacteroidota,c__Chlorobia,o__Chlorobiales,f__Chlorobiaceae,g__Prosthecochloris,s__Prosthecochloris sp002113825 diff --git a/tests/test-data/tax/lemonade-MAG3.x.gtdb.matches.zip b/tests/test-data/tax/lemonade-MAG3.x.gtdb.matches.zip new file mode 100644 index 0000000000000000000000000000000000000000..6f3405da5334a237fd83fdef909d6325165cc768 GIT binary patch literal 61565 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 literal 0 HcmV?d00001 diff --git a/tests/test-data/tax/test-empty-line.taxonomy.csv b/tests/test-data/tax/test-empty-line.taxonomy.csv new file mode 100644 index 0000000000..a197efb7fa --- /dev/null +++ b/tests/test-data/tax/test-empty-line.taxonomy.csv @@ -0,0 +1,8 @@ +ident,superkingdom,phylum,class,order,family,genus,species + +GCF_001881345.1,d__Bacteria,p__Proteobacteria,c__Gammaproteobacteria,o__Enterobacterales,f__Enterobacteriaceae,g__Escherichia,s__Escherichia coli +GCF_009494285.1,d__Bacteria,p__Bacteroidota,c__Bacteroidia,o__Bacteroidales,f__Bacteroidaceae,g__Prevotella,s__Prevotella copri +GCF_013368705.1,d__Bacteria,p__Bacteroidota,c__Bacteroidia,o__Bacteroidales,f__Bacteroidaceae,g__Phocaeicola,s__Phocaeicola vulgatus +GCF_003471795.1,d__Bacteria,p__Bacteroidota,c__Bacteroidia,o__Bacteroidales,f__Bacteroidaceae,g__Prevotella,s__Prevotella copri +GCF_000017325.1,d__Bacteria,p__Proteobacteria,c__Gammaproteobacteria,o__Enterobacterales,f__Shewanellaceae,g__Shewanella,s__Shewanella baltica +GCF_000021665.1,d__Bacteria,p__Proteobacteria,c__Gammaproteobacteria,o__Enterobacterales,f__Shewanellaceae,g__Shewanella,s__Shewanella baltica diff --git a/tests/test-data/tax/test.LIN-taxonomy.csv b/tests/test-data/tax/test.LIN-taxonomy.csv new file mode 100644 index 0000000000..1544b78994 --- /dev/null +++ b/tests/test-data/tax/test.LIN-taxonomy.csv @@ -0,0 +1,7 @@ +ident,lin +GCF_001881345.1,0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0 +GCF_009494285.1,1;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0 +GCF_013368705.1,2;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0 +GCF_003471795.1,1;0;1;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0 +GCF_000017325.1,1;0;1;0;0;0;0;0;0;0;0;0;0;1;0;0;0;0;0;0 +GCF_000021665.1,1;0;1;0;0;0;0;0;0;0;0;0;0;1;1;0;0;0;0;0 diff --git a/tests/test-data/tax/test.ncbi-taxonomy.csv b/tests/test-data/tax/test.ncbi-taxonomy.csv new file mode 100644 index 0000000000..ec3bfa530d --- /dev/null +++ b/tests/test-data/tax/test.ncbi-taxonomy.csv @@ -0,0 +1,7 @@ +ident,taxid,superkingdom,phylum,class,order,family,genus,species,strain,taxpath +GCF_001881345.1,562,Bacteria,Pseudomonadota,Gammaproteobacteria,Enterobacterales,Enterobacteriaceae,Escherichia,Escherichia coli,,2|1224|1236|91347|543|561|562| +GCF_009494285.1,165179,Bacteria,Bacteroidota,Bacteroidia,Bacteroidales,Prevotellaceae,Prevotella,Prevotella copri,,2|976|200643|171549|171552|838|165179| +GCF_013368705.1,821,Bacteria,Bacteroidota,Bacteroidia,Bacteroidales,Bacteroidaceae,Phocaeicola,Phocaeicola vulgatus,,2|976|200643|171549|815|909656|821| +GCF_003471795.1,165179,Bacteria,Bacteroidota,Bacteroidia,Bacteroidales,Prevotellaceae,Prevotella,Prevotella copri,,2|976|200643|171549|171552|838|165179| +GCF_000017325.1,402882,Bacteria,Pseudomonadota,Gammaproteobacteria,Alteromonadales,Shewanellaceae,Shewanella,Shewanella baltica,Shewanella baltica OS185,2|1224|1236|135622|267890|22|62322|402882 +GCF_000021665.1,407976,Bacteria,Pseudomonadota,Gammaproteobacteria,Alteromonadales,Shewanellaceae,Shewanella,Shewanella baltica,Shewanella baltica OS223,2|1224|1236|135622|267890|22|62322|407976 diff --git a/tests/test-data/tax/test1.gather.csv b/tests/test-data/tax/test1.gather.csv index 80388e8d9d..b453a12d0c 100644 --- a/tests/test-data/tax/test1.gather.csv +++ b/tests/test-data/tax/test1.gather.csv @@ -1,5 +1,5 @@ -intersect_bp,f_orig_query,f_match,f_unique_to_query,f_unique_weighted,average_abund,median_abund,std_abund,name,filename,md5,f_match_orig,unique_intersect_bp,gather_result_rank,remaining_bp,query_name,query_md5,query_filename -442000,0.08815317112086159,0.08438335242458954,0.08815317112086159,0.05815279361459521,1.6153846153846154,1.0,1.1059438185997785,"GCF_001881345.1 Escherichia coli strain=SF-596, ASM188134v1",/group/ctbrowngrp/gtdb/databases/ctb/gtdb-rs202.genomic.k31.sbt.zip,683df1ec13872b4b98d59e98b355b52c,0.042779713511420826,442000,0,4572000,test1,md5,test1.sig -390000,0.07778220981252493,0.10416666666666667,0.07778220981252493,0.050496823586903404,1.5897435897435896,1.0,0.8804995294906566,"GCF_009494285.1 Prevotella copri strain=iAK1218, ASM949428v1",/group/ctbrowngrp/gtdb/databases/ctb/gtdb-rs202.genomic.k31.sbt.zip,1266c86141e3a5603da61f57dd863ed0,0.052236806857755155,390000,1,4182000,test1,md5,test1.sig -138000,0.027522935779816515,0.024722321748477247,0.027522935779816515,0.015637726014008795,1.391304347826087,1.0,0.5702120455914782,"GCF_013368705.1 Bacteroides vulgatus strain=B33, ASM1336870v1",/group/ctbrowngrp/gtdb/databases/ctb/gtdb-rs202.genomic.k31.sbt.zip,7d5f4ba1d01c8c3f7a520d19faded7cb,0.012648945921173235,138000,2,4044000,test1,md5,test1.sig -338000,0.06741124850418827,0.013789581205311542,0.010769844435580374,0.006515719172503665,1.4814814814814814,1.0,0.738886568268889,"GCF_003471795.1 Prevotella copri strain=AM16-54, ASM347179v1",/group/ctbrowngrp/gtdb/databases/ctb/gtdb-rs202.genomic.k31.sbt.zip,0ebd36ff45fc2810808789667f4aad84,0.04337782340862423,54000,3,3990000,test1,md5,test1.sig +intersect_bp,f_orig_query,f_match,f_unique_to_query,f_unique_weighted,average_abund,median_abund,std_abund,name,filename,md5,f_match_orig,unique_intersect_bp,gather_result_rank,remaining_bp,query_name,query_md5,query_filename,query_bp,ksize,scaled,query_n_hashes +442000,0.08815317112086159,0.08438335242458954,0.08815317112086159,0.05815279361459521,1.6153846153846154,1.0,1.1059438185997785,"GCF_001881345.1 Escherichia coli strain=SF-596, ASM188134v1",/group/ctbrowngrp/gtdb/databases/ctb/gtdb-rs202.genomic.k31.sbt.zip,683df1ec13872b4b98d59e98b355b52c,0.042779713511420826,442000,0,4572000,test1,md5,test1.sig,5014000,31,1000,2507 +390000,0.07778220981252493,0.10416666666666667,0.07778220981252493,0.050496823586903404,1.5897435897435896,1.0,0.8804995294906566,"GCF_009494285.1 Prevotella copri strain=iAK1218, ASM949428v1",/group/ctbrowngrp/gtdb/databases/ctb/gtdb-rs202.genomic.k31.sbt.zip,1266c86141e3a5603da61f57dd863ed0,0.052236806857755155,390000,1,4182000,test1,md5,test1.sig,5014000,31,1000,2507 +138000,0.027522935779816515,0.024722321748477247,0.027522935779816515,0.015637726014008795,1.391304347826087,1.0,0.5702120455914782,"GCF_013368705.1 Bacteroides vulgatus strain=B33, ASM1336870v1",/group/ctbrowngrp/gtdb/databases/ctb/gtdb-rs202.genomic.k31.sbt.zip,7d5f4ba1d01c8c3f7a520d19faded7cb,0.012648945921173235,138000,2,4044000,test1,md5,test1.sig,5014000,31,1000,2507 +338000,0.06741124850418827,0.013789581205311542,0.010769844435580374,0.006515719172503665,1.4814814814814814,1.0,0.738886568268889,"GCF_003471795.1 Prevotella copri strain=AM16-54, ASM347179v1",/group/ctbrowngrp/gtdb/databases/ctb/gtdb-rs202.genomic.k31.sbt.zip,0ebd36ff45fc2810808789667f4aad84,0.04337782340862423,54000,3,3990000,test1,md5,test1.sig,5014000,31,1000,2507 diff --git a/tests/test-data/tax/test1.gather.v450.csv b/tests/test-data/tax/test1.gather.v450.csv new file mode 100644 index 0000000000..74921b8e76 --- /dev/null +++ b/tests/test-data/tax/test1.gather.v450.csv @@ -0,0 +1,5 @@ +intersect_bp,f_orig_query,f_match,f_unique_to_query,f_unique_weighted,average_abund,median_abund,std_abund,filename,name,md5,f_match_orig,unique_intersect_bp,gather_result_rank,remaining_bp,query_filename,query_name,query_md5,query_bp,ksize,moltype,scaled,query_n_hashes,query_abundance,query_containment_ani,match_containment_ani,average_containment_ani,max_containment_ani,potential_false_negative,n_unique_weighted_found,sum_weighted_found,total_weighted_hashes +442000,0.08815317112086159,0.08438335242458954,0.08815317112086159,0.05815279361459521,1.6153846153846154,1.0,1.1059438185997785,/Users/t/dev/sourmash/gtdb-rs207.genomic.k31.zip,"GCF_001881345.1 Escherichia coli strain=SF-596, ASM188134v1",683df1ec13872b4b98d59e98b355b52c,0.08438335242458954,442000,0,582000,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,test1,9687eeed,5014000,31,DNA,2000,2507,True,0.9246458342627294,0.9233431290448543,0.9239944816537918,0.9246458342627294,False,357,357,6139 +390000,0.07778220981252493,0.10416666666666667,0.07778220981252493,0.050496823586903404,1.5897435897435896,1.0,0.8804995294906566,/Users/t/dev/sourmash/gtdb-rs207.genomic.k31.zip,"GCF_009494285.1 Prevotella copri strain=iAK1218, ASM949428v1",1266c86141e3a5603da61f57dd863ed0,0.10416666666666667,390000,1,192000,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,test1,9687eeed,5014000,31,DNA,2000,2507,True,0.920920083987624,0.929637921884656,0.92527900293614,0.929637921884656,False,310,667,6139 +138000,0.027522935779816515,0.024722321748477247,0.027522935779816515,0.015637726014008795,1.391304347826087,1.0,0.5702120455914782,/Users/t/dev/sourmash/gtdb-rs207.genomic.k31.zip,"GCF_013368705.1 Bacteroides vulgatus strain=B33, ASM1336870v1",7d5f4ba1d01c8c3f7a520d19faded7cb,0.024722321748477247,138000,2,54000,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,test1,9687eeed,5014000,31,DNA,2000,2507,True,0.8905689983332759,0.8874914330230439,0.8890302156781599,0.8905689983332759,False,96,763,6139 +338000,0.06741124850418827,0.013789581205311542,0.010769844435580374,0.006515719172503665,1.4814814814814814,1.0,0.738886568268889,/Users/t/dev/sourmash/gtdb-rs207.genomic.k31.zip,"GCF_003471795.1 Prevotella copri strain=AM16-54, ASM347179v1",0ebd36ff45fc2810808789667f4aad84,0.08631256384065372,54000,3,0,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,test1,9687eeed,5014000,31,DNA,2000,2507,True,0.9166787698053748,0.9240166714766321,0.9203477206410035,0.9240166714766321,False,40,803,6139 diff --git a/tests/test-data/tax/test1.gather_old.csv b/tests/test-data/tax/test1.gather_old.csv new file mode 100644 index 0000000000..80388e8d9d --- /dev/null +++ b/tests/test-data/tax/test1.gather_old.csv @@ -0,0 +1,5 @@ +intersect_bp,f_orig_query,f_match,f_unique_to_query,f_unique_weighted,average_abund,median_abund,std_abund,name,filename,md5,f_match_orig,unique_intersect_bp,gather_result_rank,remaining_bp,query_name,query_md5,query_filename +442000,0.08815317112086159,0.08438335242458954,0.08815317112086159,0.05815279361459521,1.6153846153846154,1.0,1.1059438185997785,"GCF_001881345.1 Escherichia coli strain=SF-596, ASM188134v1",/group/ctbrowngrp/gtdb/databases/ctb/gtdb-rs202.genomic.k31.sbt.zip,683df1ec13872b4b98d59e98b355b52c,0.042779713511420826,442000,0,4572000,test1,md5,test1.sig +390000,0.07778220981252493,0.10416666666666667,0.07778220981252493,0.050496823586903404,1.5897435897435896,1.0,0.8804995294906566,"GCF_009494285.1 Prevotella copri strain=iAK1218, ASM949428v1",/group/ctbrowngrp/gtdb/databases/ctb/gtdb-rs202.genomic.k31.sbt.zip,1266c86141e3a5603da61f57dd863ed0,0.052236806857755155,390000,1,4182000,test1,md5,test1.sig +138000,0.027522935779816515,0.024722321748477247,0.027522935779816515,0.015637726014008795,1.391304347826087,1.0,0.5702120455914782,"GCF_013368705.1 Bacteroides vulgatus strain=B33, ASM1336870v1",/group/ctbrowngrp/gtdb/databases/ctb/gtdb-rs202.genomic.k31.sbt.zip,7d5f4ba1d01c8c3f7a520d19faded7cb,0.012648945921173235,138000,2,4044000,test1,md5,test1.sig +338000,0.06741124850418827,0.013789581205311542,0.010769844435580374,0.006515719172503665,1.4814814814814814,1.0,0.738886568268889,"GCF_003471795.1 Prevotella copri strain=AM16-54, ASM347179v1",/group/ctbrowngrp/gtdb/databases/ctb/gtdb-rs202.genomic.k31.sbt.zip,0ebd36ff45fc2810808789667f4aad84,0.04337782340862423,54000,3,3990000,test1,md5,test1.sig diff --git a/tests/test-data/tax/test1.sig b/tests/test-data/tax/test1.sig new file mode 100644 index 0000000000..481b5c0fe5 --- /dev/null +++ b/tests/test-data/tax/test1.sig @@ -0,0 +1 @@ +[{"class":"sourmash_signature","email":"","hash_function":"0.murmur64","filename":"outputs/abundtrim/HSMA33MX.abundtrim.fq.gz","name":"test1","license":"CC0","signatures":[{"num":0,"ksize":31,"seed":42,"max_hash":9223372036854776,"mins":[1801780449053,5458252130166,6591188866254,7120172672879,9082284241737,10728892769606,11588978090500,15122585497543,38399860862315,38438621742505,38957650378822,43642908550311,49190350589363,51241489808017,52038516396820,55626183649127,57931785979476,59475646712940,61888305980170,66691310756715,67713033308653,75460475050982,83068597888631,83912389770482,85873192955019,89837278809393,95623763017500,100618943545846,105589191839212,106327290429078,107895226648799,110894517505046,118020225715052,120034610939834,122369256187011,129289965512763,130928026111262,130938340566651,143174264538985,145071249202422,145671304937307,146739400278221,148235757769851,155386635885846,155677692390406,157116103504068,161795715147973,173732370460792,185229242653784,186022686250154,187689319577746,189304177974813,193902172849200,194242832207592,194881377708977,195759842636732,196522487725705,199751052673835,202146507998907,222425615468407,223249068104148,223337347537074,234925665957554,235536589602118,236884593101873,237482514982629,238592860065117,238960868690792,239992873687381,240315880694735,254158187932000,257482521227605,263203897919060,271907056950107,276063588320093,277780270194567,287181861997645,294798985986751,295117776313199,295321171375011,296954766319431,298364452579527,299194776297775,300315718271153,301174169934255,316143524665102,318835240107741,321837183083411,327832342407486,329774746026349,330701474946204,365835128310405,368231249024867,369476789881841,371226893144954,371660206237011,374226531957468,393732697583324,399530793495612,399568875534418,401432518761453,405696412569371,414261066416938,414824383009183,414887886466360,421198715108517,421826837480120,424429611918972,426487435587286,432666075993020,435421536016940,442469438678934,449054785805222,451463529208473,454299011589459,461520410511837,462656026625622,470162480270336,470694308016950,473585890759779,474012770324396,474884042197311,483900958333102,484907745635820,486845094947745,489990407133101,494050464054577,499281851171754,503578160655379,506828194433639,509177593267028,512448947565770,513942885771890,517829035312474,530057483442435,530082702221410,535476130416480,537932192425038,539890950109404,540972427773914,543092013343014,543506671350588,544499779570225,546385392671462,550377864086195,555849123648892,570964545039248,573370565815734,576345508540445,577575466338476,578319808924488,583512473386290,584077338234746,588536675744659,594922452783793,597568549861897,598890134630041,599782102595140,604029017352300,604508291421975,604610702083857,604694867927366,607861111123954,617483065184374,620354340182593,624524714692492,627381481054398,631269010577656,631593571578775,632916308544369,635176670871932,636471565348712,637949264380072,638725952045637,639329210156146,643342131923252,651095673816180,651242275725635,653164384523604,654489030634985,657242420999021,660445358810982,664894928532840,665629961220564,668107465223873,672113068883877,673775303377613,675837887190665,676968160653458,677445353598153,678928732569771,678971833695174,682421025215358,695804743576190,697617084541107,700290936794347,703137372282060,705323357572920,706170744031789,706547905236572,711134002608102,712403114406310,716146010545846,717526351100828,723701125952460,729853078675623,731228554406689,739307677490185,739858405136959,746753715000952,755683778068123,761157564699545,762054707162542,765496955546010,767698515697153,768275120068981,769316232958423,770279713530443,774338991559281,788905341152332,790131255184311,792434502377841,800629059513587,803873764133334,816756339487181,818071814024525,823673942075222,829088482009743,831823618098275,834609210220628,834642365556010,835286263888349,839156619225359,841468722623114,841602840916107,843480928415649,843488702887894,845055175633863,860039483120428,860363170334648,861085776178355,864189045378647,870252397081242,874522530248982,875841188482833,877367032528903,885158499009481,886469269081226,891892847635470,892603598456960,893326023666627,895477236868157,897008697810136,905675571540520,905962314990908,912281923942709,912592835412045,913170301186791,916366405492479,917650904976714,918002282400195,920021865215314,922047784696827,922802240738664,925677457650295,938058573846060,939831781303942,940174760644479,942757927258442,944606546988037,954043786818990,955806278560367,956863571036549,958816068493539,959483107852792,961612188485024,961615695888050,964671864972039,977319599678175,982814756712554,983244880945329,990529504002903,992234302172021,993672633587162,994123500937892,995179254637510,1000365607038708,1000869249430303,1003296297753093,1004323046137891,1004439285428660,1007363480602977,1009844038227932,1013164280561716,1022886051012134,1027560817545623,1028449007204112,1029378539918835,1029898165207505,1031008329505068,1034993325442721,1036516029970066,1037363969213176,1048056659748520,1049920909091433,1055647818593054,1059382992165024,1063914075274140,1064413489986935,1065581279754315,1079083598119399,1084322427426975,1088070490885858,1090221909043836,1091101901911999,1097845569444490,1098576884477442,1099748298326141,1100000664298648,1105190591682860,1110245494827884,1114723873361348,1117966100416351,1120704151152946,1120754801794571,1129802671706778,1131744411948725,1132295099051126,1134448864501462,1136749459756830,1138074347781431,1143192255417291,1151075783590878,1155079643015325,1164744365104087,1164906270469961,1167274348640009,1169741951508292,1170363563329506,1172713997847744,1181282928197399,1184561837198645,1188330700347196,1192259851549345,1193204811945513,1194582325968975,1195149546188987,1196032300947061,1199736127286640,1200017229415285,1200986260460958,1201292224333533,1201891339411160,1205017038237614,1205670765767214,1206301041371634,1207429658776297,1211100630681845,1219460571018891,1231088706614037,1231757756653057,1242283086447824,1242819553064222,1243222208708735,1243440662272961,1244070872057599,1246332185182392,1260607474737333,1262544336428099,1266877332627123,1268611119735054,1281894798835225,1292443275389310,1293246097302243,1295502311921774,1296938619972523,1300745872589390,1310883863490185,1314795508490518,1315612665560528,1327983167558196,1332699297517306,1333391290150697,1335977403684212,1338704596860924,1343883296848257,1348202460653710,1348397177664945,1348887151833681,1348956580069856,1349979004401411,1352058152838659,1356178501599322,1363636898101019,1364427785485318,1364911389879927,1372870030833976,1373839232488879,1375023263772681,1375765410230186,1375800380558957,1380195785511076,1380852801369884,1383769484520175,1390861767235063,1392415036853493,1396838503711111,1405309222702215,1407542880544789,1407631146624796,1412637482113617,1419114560401612,1419175689024567,1420531218100792,1423784381925651,1424603513320807,1425346572048593,1428453519004746,1431361379523217,1435313615399398,1435743944044254,1438196281438527,1441340546762016,1442559300585617,1442765679656428,1449768981167591,1451152581988513,1455329173300136,1458011214012063,1462047370133181,1464716206822304,1465535733795585,1468922365399059,1473245182807340,1473696586981641,1475525613421914,1476101282708896,1476188188706320,1485868855870969,1490019405076614,1490451081476970,1494641984397995,1496435020044719,1503266352292375,1503814372310290,1522448555353404,1522576405787558,1526480840826446,1537220436879970,1537490875260637,1538454298780103,1538578226987402,1546115910740139,1555312655792722,1557963413374908,1561791585349093,1564885968899289,1565779279079441,1566859820085194,1567284877391746,1572621778797619,1579535081965748,1580073630926132,1581639354788742,1585306756265488,1585991864242405,1588620873364380,1598898499662073,1599731569802516,1600553402869137,1600863308137304,1601859562453586,1607094664639497,1609420790553090,1615593470949610,1615693906714788,1617712248194709,1622780863897477,1626826565112645,1641805274007335,1645962814555745,1647120835172573,1653724265973690,1654185631330349,1654794065836700,1657716166792685,1661614756553044,1662649676567225,1666978914304751,1670502364746609,1673985511388650,1680218973636861,1683886629191164,1690167237344575,1691173272662963,1696867940152942,1698933246544353,1706005500358627,1708123845429802,1710062500579154,1715123263969432,1716070582981795,1723304952024848,1724858959724804,1725792587066562,1729428957555687,1734020360111201,1737670240318811,1738387893705466,1745766632845255,1745881931555689,1746758990440875,1749529671768288,1755020621548824,1758808360017853,1758856095873638,1762481692273195,1765122704488989,1765419213961161,1774043677869583,1776893669882995,1778819386718525,1780294324439594,1780705857352269,1783931786751622,1791409204601019,1791412896857529,1791972719083046,1795946603874813,1796805217888098,1800022424371565,1801466174404076,1802529433840948,1805512141186030,1808005382256341,1810997540602971,1812308362903706,1818443122286087,1827887663610402,1831039902057540,1840888799526783,1841637123637928,1848759299048616,1857774883129471,1857915592550210,1863402082996382,1864846137142018,1882818635918613,1886589755462702,1890021800958452,1891416417080755,1899119475269806,1900074522453948,1901924538449401,1903126309653705,1907129748975317,1908019659828823,1909690948685424,1915600809801493,1915739029753608,1915890280960717,1916733895312715,1917061739080878,1921720835553826,1928652280159263,1929726167249370,1929766564958451,1930596286017212,1935757887323278,1944158955329884,1945487580782233,1948320505746319,1951243185050906,1952729191065571,1975735738888660,1979245363373095,1985168955345106,1985361549317003,1993067300825063,1994817497620989,2004427918856867,2010945500170294,2012252632804436,2024401150993101,2029864374222602,2032833741267073,2035023083408834,2039085931028657,2041378799655967,2042991805382749,2043804091896448,2046535153469143,2047196781345255,2047467566661316,2049015951943152,2068513980772065,2069829482912071,2069946417090816,2073944204552497,2086004144478581,2090346141546874,2091005953459795,2096006950553362,2099534977032916,2101607625311550,2102410321707389,2102936143700971,2105286417710797,2105413109440643,2107287404659830,2109861366624094,2112954439001265,2113548364648582,2115022127315811,2115935312879348,2117291269264290,2123615035656195,2126475563771931,2128962075639992,2136891182298581,2142906221409061,2150286967174143,2157354859441371,2158584382181405,2159000155231542,2164402285956991,2164420539897788,2166184944072269,2169512198628278,2170846979945687,2171884942517733,2175382403973377,2177358145278765,2187096589893718,2190658323509656,2194591702892886,2196260569034245,2202859371040818,2206133002291658,2207617401909658,2214788036179856,2216052652978319,2222942237490572,2223234285341173,2236790969989460,2243330175490398,2246031431497596,2246726782866613,2247060633572090,2247688610824376,2248601132778848,2250092614370659,2252831322558124,2253262996341926,2255637485672851,2262525152522802,2266948431784474,2267653332963989,2268282858459638,2269685385879672,2272672674820255,2279976713364220,2281047960939008,2283894414067801,2289386219153711,2294339095279902,2295036800418722,2295861902691084,2298825521542545,2298870704971242,2300447068748498,2300681332528186,2302646141344824,2304673423405180,2306027882581463,2306865358207193,2313627878888201,2330114296836543,2330295673756257,2332730403881402,2335774283878130,2338226189665994,2351243207491915,2357051942101470,2361743355642517,2361862357138519,2363127392947574,2365300891811803,2365737587530894,2368414199062538,2377503634254215,2379443148322302,2380692165304719,2390410050991404,2394447043780677,2397502283699683,2399712657600486,2399902832571923,2414302847216427,2414313471993391,2420673615935213,2430548711003002,2440439983231539,2441314540439710,2456984538412858,2460718349716323,2461069689927847,2461125261724502,2462092950078415,2462414800439850,2462537227157028,2466234489109569,2466541365902227,2466961313499802,2468023510176418,2468231442109743,2468308785469834,2472207837117808,2476866549999012,2478731521326094,2484941965264213,2485278676601261,2492581924887592,2492720594205232,2495903129317672,2503786485553208,2509275552045137,2522146431491981,2524785376202218,2525751964964437,2525996503638112,2527439017835592,2532690539060427,2544399025369543,2545123276865361,2545648384570956,2549653605168452,2556893127371295,2557594953710116,2568668630407302,2571933836171716,2578959796175690,2580657808427011,2592069912642927,2592593312263972,2601138817700758,2602474943434014,2605868804804596,2608681461227337,2626014893616251,2626498367838865,2640283972455623,2640301509047574,2650380190569745,2652297219267770,2653047694632744,2653620452308281,2656917475152007,2659337250875384,2660967200113845,2681797971093930,2683571534496408,2690648185162126,2695362405330576,2695473553065393,2695941499148681,2697996868015658,2699691781458882,2701441914236657,2711890392398106,2723051391916714,2728164525358733,2731403578488737,2732021978188180,2735095463326346,2738717357825734,2741123127480130,2743316065416619,2761873391720730,2762805962964802,2762843472148957,2782120271963068,2786278579190588,2807165616708232,2809576875609255,2822187052814145,2824971226984202,2827067397771123,2831260535793820,2834188873133334,2835499181363886,2836022314285012,2837245134626451,2840357720393624,2851544472953861,2858870801942861,2860970971814747,2861055399489953,2862493513884951,2863927993571259,2866233396957443,2870405354047784,2872122750063840,2872562942080889,2876266560041161,2877362030683796,2879741333483136,2881933437332361,2888805331991586,2889995142339069,2890599453670893,2892230192215957,2898683898241537,2900093175620697,2904255720092449,2913236264821117,2916314340809717,2916872885601179,2917569957165633,2920548876251152,2920876759028211,2921087676055016,2929983967643845,2932002960896196,2935877171818752,2936580102378665,2938759946396870,2941129341085443,2944261628502791,2949682600073917,2954439153654049,2963069771185193,2963537392165059,2966128106991971,2967618901186066,2977341903733332,2978357003774438,2979637244007009,2982475882379606,2984268486937331,2985753144073493,2986068781898044,2989100645339806,2990781071784624,2990861021249633,2995583252985831,2997980913432191,3005021593267291,3012010012918981,3012322503302601,3017242284136368,3017532355268491,3021613785253128,3024594380101525,3024857657834164,3034168964260376,3036481479941613,3039765721301307,3042993491069170,3045572683103312,3056918838623779,3057132793001749,3074948005728708,3075809619223105,3080876389418406,3082860374921744,3083489931088814,3084609109111291,3086434505598033,3087149002429294,3097237099692426,3098055065568993,3101442461802476,3104952125204041,3119291438672126,3120317070232257,3120368944841608,3123429793959964,3124619941244412,3126486824541362,3128528014106262,3134617318884365,3136881148528327,3148495457743039,3148723137451394,3159301843822713,3162003995955198,3172556808537059,3186232057007736,3191973742740356,3192046592831254,3193652048583582,3194559415275232,3205945560528680,3209006700923391,3212718583443185,3215946848669414,3226080608147842,3231663231955234,3240159615671910,3241655087845142,3242877830553424,3247814230437521,3248645505322919,3249446384376126,3251687033468579,3251699659512866,3253558938683032,3254802973362809,3256756601420500,3257122665096711,3259410798675734,3262694930156135,3263115533949087,3263804080308242,3265753405701318,3266732162137051,3278200222038346,3282016467656596,3282029934313741,3282338154478941,3283658299978063,3285726137707735,3293189910488950,3294816857715236,3296739788778720,3301602385834638,3304343775645059,3306269425792952,3306776778730397,3307209605344671,3311788314251926,3311797572074338,3316814952673629,3319525428621635,3328800127175148,3332265635070097,3336960097566980,3338679259227622,3343887024580328,3347094642198370,3348220819677538,3349154543061834,3350201701485193,3351390468060099,3356669951756788,3362362800295617,3363910023821603,3364549181025886,3364885264151885,3366069699651896,3366461810555685,3367515171045040,3371220596328921,3372904947603937,3374167622999393,3375362268276893,3376266064834410,3380934904882187,3387317885099637,3391343891569301,3394529021971256,3396259377871554,3404166413220666,3405531015988258,3407193133234483,3409948428215076,3410355542153309,3411312030031663,3412915924045996,3415412968307482,3415435138305639,3431052729717729,3435187978051306,3443367445617505,3444614537398093,3451057156510418,3452464509409613,3454835026235403,3458028164885252,3467610601170706,3468462020349332,3473978376714820,3474085764738899,3475960521071723,3475987826776457,3479076514110271,3482513634206101,3484678615151652,3486087952023396,3490961111674858,3497586744407739,3509228941461870,3511023197649329,3513564746848042,3514412260259040,3515161451249722,3517225815427077,3519977455439944,3520746101368097,3523001373715962,3523097397851302,3523452531596611,3525188766315725,3526396582150867,3535980638143535,3540051580269089,3540659201571637,3543517932814205,3547116467415506,3558345460668827,3561189613180753,3561926592352803,3565997018443966,3569173528663999,3573559069229078,3575055235532490,3577513299114271,3581749865080594,3583099077897504,3583603371952673,3591787084511951,3594557272413786,3595184237431217,3597677892111537,3611073305780163,3613163358158435,3613821059597281,3614974876390329,3618347858087430,3619989298464100,3625892382978548,3626521191250342,3633261117882124,3633429457694247,3635142179077472,3639084998655068,3641614311282192,3642110981076818,3647194457528132,3650008403890242,3656188330380025,3659799492909609,3661276585315533,3666304256350575,3668119887243300,3668609597329297,3670075776623956,3670653129298244,3672607366393421,3673782792777809,3674258359470075,3676570931288345,3678306426195425,3679952227105411,3687099486189865,3687137444506499,3687430488160604,3696762480541271,3702103761959203,3705203442100892,3706276255120858,3711439764748957,3714570291171692,3714966331862513,3720530786326219,3746558501445836,3751772162528691,3757109447871504,3759973311404690,3761987782165253,3773228301540179,3778789616176660,3783212147361096,3785261421639986,3795738486500412,3795840262237822,3799921472278512,3801604748484266,3806524903398788,3808841177306784,3808863563474781,3810481275701586,3812513976586452,3815838315339824,3817629127842281,3822393702976202,3822410766295522,3825220533863655,3825747651591335,3826776367064649,3837032217009853,3837632446946553,3842517683007875,3847301724271834,3851637847184113,3857224074056912,3860328908886126,3863476688079568,3864397175659268,3866098975133454,3867419706837698,3868683565193646,3871011535469427,3874164570951557,3883682772633387,3889257066625032,3890297557788740,3893416850637854,3897705741853375,3899123787205423,3906583184196861,3909200598965478,3915978337757168,3921401668003542,3926978543962638,3929653036460697,3932297725317506,3937666860251872,3950980637467369,3952336760994600,3953607468356622,3958511413896334,3959671477824962,3962793138819710,3967186799098668,3974668799499890,3976748096931913,3981598640143000,3986335601804535,3986775429956611,3991204578375011,4001464723683728,4002600882371641,4011738356061424,4012359035236625,4015261028552500,4016302498682268,4020146246575155,4021488712721046,4021760992167191,4022003782169240,4022033596678263,4030275050062964,4035015632298670,4044505938259683,4045286021931175,4046089336117915,4047131833430305,4049668287635123,4050546162218507,4055183603394470,4055372742636031,4059660898861282,4062240515392553,4070102312077672,4075572307324178,4077604748855687,4083140886062718,4085737263535009,4089523429942642,4090123772606916,4091707602304575,4098972214813422,4100248529190520,4101361174893674,4103049872626335,4103719077942030,4104154827640847,4109453733716460,4120902877538683,4121236747514406,4123221990581825,4130709016609859,4133171043342310,4140909285058536,4145373020742977,4145586397774238,4148457556113189,4148884287349063,4157913782848054,4159540182709357,4160833019603394,4162094561033100,4172825426828376,4175775970742079,4182932115663073,4185401396085971,4189064413754290,4189573056310503,4199784985986636,4204222949061495,4221704521677516,4231066053714131,4233389175344776,4243972763409874,4245297129359012,4249583617601613,4250607043241093,4253753046800996,4254831826846719,4258264682936584,4261175427894812,4261618531893470,4262258385113951,4263514748069004,4267361425600823,4268240420295104,4270410397688804,4271469483913918,4271589821052522,4275786190883653,4279740367593536,4280072580428140,4284561215821303,4294411576133743,4298889997454565,4312175431669831,4315556281520508,4317246903405374,4324103247452990,4324909685504015,4325467935195616,4325502895827937,4334058232515438,4338477327926905,4342739478351861,4343698994677152,4345864418778705,4349057325031727,4351158882837894,4355154606517363,4358316113915131,4360333291920398,4362889447276041,4368198665017499,4369759111122664,4371495056318493,4372750663939236,4384145747468688,4386099025982685,4386981829320652,4387520450543831,4388922957086687,4403999958830993,4405035262658266,4405827352702082,4407044220097938,4408032881121899,4408828070875987,4413257885043313,4423215556349903,4424021319064632,4424674978521889,4425760722257444,4426398058526602,4426561067629944,4432199962831179,4445106471943215,4445487152361071,4445919583211301,4446472005990224,4448656712936154,4454061234378328,4455427615590011,4466291582359341,4469923218534886,4471027992940562,4471070409430366,4471561762187885,4486533589744328,4487951762183563,4495867233548954,4509312299524297,4513935813310166,4515050504284108,4515845312456529,4517556194493336,4519286918750086,4522655483838222,4530419611015290,4531969119376399,4538859735757906,4542464099648655,4543519980607845,4547129511580202,4558683168753481,4568135063604348,4568534456108997,4570357987932056,4574484090688622,4579675012659972,4580835436054849,4592124755703246,4592484113678208,4592720099391684,4599670725818188,4601200249713104,4602376910436860,4606759266911014,4608179131870933,4610742946581301,4612668942795427,4613375762061302,4626849241528505,4629121337207112,4631516742274642,4634817499672638,4636177934038893,4636841104325787,4637486920729328,4641676229107421,4646863849350954,4649700485456595,4650092649874060,4660972426360767,4662217832865279,4665551841704227,4672433666224124,4676135170641509,4683538938476771,4686214501916734,4688567448953581,4691460160491728,4698079234886708,4708758580215389,4710816804514073,4711939441759228,4722894620050178,4724309976746768,4741110783559225,4742764349277666,4745175064196402,4746598415145140,4747193312910243,4747323664192223,4750876563823778,4760265712816929,4762805650900745,4763844072202813,4769667228163598,4771442164037376,4775799099636579,4776590626090075,4777326227802484,4792357847255715,4793056977635364,4794059501642635,4796729383293325,4803818310447618,4805184726396905,4808631657758343,4812413749483915,4814326972753395,4814702861725669,4816706409543371,4818106224832139,4819534829447841,4826407489088596,4829583636048657,4831608612596571,4831692274060710,4832346225073598,4840568016285383,4843368480194748,4847300048377989,4855514753861602,4858039563037400,4861517360370008,4869650151350361,4871708166097927,4877581610405069,4877910005645883,4881360356743371,4887450400297632,4888198566972223,4896581692976633,4900748234075707,4904791129800219,4917954135740675,4924193405457095,4928329177929897,4937456171959818,4938398798040403,4944478442836009,4946600498800090,4947003624223434,4947757006359734,4949659957115828,4949666736718054,4949869346686690,4950306135519599,4953187708199956,4954994063282966,4959558328188393,4960963431058942,4963342374841747,4967950556517484,4969814407028390,4970861431177619,4975118632487139,4975824433834285,4975853965379162,4977338216119291,4979092573080120,4985206059400515,4991298613645268,4993349948700889,4994759201280750,4997645463495799,5012402331964492,5012424625310141,5012591272785052,5013824570113291,5014077122651238,5021481745639531,5025823504759239,5026163178630690,5026745960827764,5027588305097305,5027683065020767,5032690171089936,5038156091552291,5038964563528761,5039183768647730,5042453827569848,5051838923808709,5061549168503853,5064767626400137,5070921871553233,5082282969261561,5083844666941178,5091035555292849,5092289223717401,5095027517049772,5095895306756453,5098334946630952,5099592499331759,5103864115990241,5108677590037921,5111558942218614,5121534084012110,5123142978971956,5124105546346543,5144683611815941,5148299936326704,5150390713990643,5163950810524785,5164682031313958,5168789902694576,5173326888799052,5183284806184347,5184817643868070,5186604611749758,5194729111398227,5200147548626186,5202487603456236,5208391766475711,5209711415985400,5214973095190263,5215525446538726,5223744080771508,5238808315364320,5241710516410329,5243390386512090,5249786984476194,5253447001717237,5257672746685787,5258407423596500,5277128991279337,5279352139637624,5282445080369168,5282854290956042,5282892258305804,5286687370957295,5290896664018699,5294231625739159,5301094414079043,5305946331540850,5306516004197708,5315879687536534,5319023578897262,5326045907075497,5328574498640536,5328835804106447,5334188729111025,5335932022395669,5340953760073680,5341655832839889,5342490466816752,5343452240588540,5344728520428441,5344998611131536,5350146362680912,5350188409903935,5350593081138481,5354015688767095,5354556066417852,5358553614181532,5361152282682509,5361750669894395,5362107533870652,5362820535437519,5369369545695003,5373484986707489,5384441125502793,5385600177619164,5390241698545424,5393185883383968,5395233479138078,5397344924019173,5397897506355104,5398764028827457,5399278179609357,5400339888291504,5400764112774817,5405221004032356,5406693121609984,5409797027590490,5419115050484204,5440089461772718,5443208823321743,5457159741795309,5458894118934905,5462919466888111,5463551498573854,5464664373796390,5470505227183248,5471921437621774,5482581331970580,5490530158137841,5493959696001017,5495962179726042,5497313119524423,5505001122182252,5520374729945656,5548129135610428,5555382992249469,5555810221184750,5558446474137143,5560140941629655,5560598318643148,5569034418735585,5574427950401764,5577537134265648,5583747070470531,5585272355119494,5586731766377701,5587168200335544,5590741795375223,5592766821054751,5595164653600275,5610955027795057,5612215802311667,5612752283439119,5617468450577650,5629340093151382,5636469395537530,5644041602528962,5644639016036119,5665343265095071,5666115559356376,5677270991037391,5687042746053034,5696880752579599,5697086267344607,5698246086462328,5705600611069117,5706150314049659,5708990986109882,5714169173510163,5726014930333932,5731865337017337,5734150346694612,5741390353267711,5745887772036050,5752153927517399,5761847773354182,5765977754063405,5776926494790713,5784587553604106,5786206347615854,5795519416369748,5798826689295463,5804195759020566,5809004394725702,5810664422041911,5811122339745379,5820456711675491,5828985924345182,5833074460300141,5835001566402112,5835423890189755,5838422201622756,5850111214818962,5854691939522253,5864441624469923,5871444470059494,5875242908162576,5876151524882478,5880748779284574,5881328323253846,5883377517678630,5887389422525342,5892543621470958,5893832670063934,5896317600358404,5901470224255200,5903586352993933,5910389103153878,5917366168098057,5927221750391596,5928435517708453,5933859039248256,5937584992257800,5944161741750234,5953411707228951,5954472574047037,5957786634369302,5964893359862650,5966571493303769,5966742224801194,5967289308405473,5974488957381518,5974740363943841,5977348747045998,5980819663699087,5991209826373643,5996116271748813,5996382404732049,5997886630931569,6006003522030562,6006051277013828,6016389571283929,6023331169782698,6031563664545611,6032664322089426,6036225393092010,6037793672781933,6044517930853221,6045731651656459,6055635766227850,6056613524943475,6070538173649014,6071500018719792,6073244595199851,6090347376712589,6095767012353734,6095985873855938,6097701935110589,6097720724158494,6101344333551360,6103353935786409,6114794255726467,6115880042056811,6117430364331735,6119249285251268,6126480768404909,6128748399804539,6131412831428059,6134834062890567,6136895327189160,6145221742324653,6147535468229988,6149838160072994,6158328271667606,6162397526638987,6162595275444691,6163557368799413,6165405045694814,6165609512000106,6173669422973318,6185118098775300,6186010776154519,6191347336663397,6191595550467591,6209954789134668,6212421015042726,6220271513905707,6223373400033741,6225930094888456,6230069862777896,6233448992120663,6238696315746473,6240737755806908,6242961273434693,6244667501609996,6248290187673189,6249048573424311,6255964038672980,6256665551146250,6270612927915784,6272436152754713,6272554580438529,6273960805125149,6275418474099558,6277497294134085,6285596491473030,6287134109902528,6293070302857573,6293797814674816,6296634091481985,6301970594220786,6306645106591724,6310441329886836,6317550486422329,6317640540081751,6318248157331005,6318806290819628,6326966388088961,6331001228684326,6337187615704033,6339909379682902,6346592881467573,6351545841419910,6360353634311445,6367708519617801,6368121512681390,6369523917275263,6371646766617320,6371963401738002,6374585713522036,6386133489806320,6388370127947934,6393672437482669,6399506419436975,6422157053160525,6423362793412252,6429307546239831,6429955026729605,6447071483578076,6452632428173785,6454792613686647,6455143302791289,6459420175166787,6461815700360898,6464136188432434,6467353559429891,6471029607227187,6479359487487582,6483576376762715,6487943278087410,6489933752002900,6501805116780850,6521474309313201,6521536791169732,6524470062670185,6525358803347767,6532512059175580,6533697300256099,6538852007746981,6538947135665113,6539568408400989,6540680810942391,6541260923210145,6541976736806940,6545350312932024,6548084687841241,6548294955909228,6554551405562154,6554873013643433,6558073060148216,6559432710302600,6561905009108487,6568660478594809,6574498946147821,6576543366778718,6584434492740196,6587145099728708,6589052409595931,6592274886266715,6600498827047933,6601721694466143,6604283708515271,6610870371812843,6612028622839800,6616071771618181,6618710917152279,6619561469900802,6622184661592400,6637074709482215,6641206460142510,6641938267473932,6643598614625064,6646664321107731,6648197786821176,6648969726400614,6650072386180730,6650711801152314,6654047832845013,6654437736965152,6657689347715594,6660513452214415,6664111531377278,6666727481964756,6667428381600667,6673787217463208,6676561920851288,6680198396726574,6681849744372237,6685621293010279,6688886533013245,6689986077074654,6692846564445826,6700989972964688,6702063619684611,6704535866851293,6707710577514020,6717254242447956,6722575088118708,6725966280369678,6727655147910665,6728358680782460,6729176885669208,6730972436616264,6737786788726773,6739671431249749,6742998772227818,6745301062032020,6746644338135217,6749228833087837,6749530337415903,6751212841104459,6757099644926750,6757457068438462,6763844198782384,6766596893449567,6766624575585630,6768465704804464,6770574421746828,6781164837405494,6784519652705764,6786942099172111,6798135136737248,6798180383104193,6800332756484982,6800851946237520,6803421053944474,6815991008866536,6819962386869178,6821494674048661,6822828370922974,6824508540089590,6825481656090207,6825961535853739,6829082362854129,6830716517069211,6831734269408780,6832017622047216,6833166457367821,6837964996477452,6841490935031713,6843041443327353,6846207535187049,6848077152196586,6855621111015190,6857301435252192,6866413100360799,6866889622766115,6867935417267992,6875342151752547,6877270474078325,6878163935685044,6878528909725328,6881291203934159,6891859245584880,6892808692045269,6895338921154961,6895739851400366,6896709362215936,6896725434612650,6900959103084557,6901289352911720,6905299714112828,6913711887485025,6916964405756177,6920074607255316,6931879913546160,6940598351486984,6940770826028617,6942173712829643,6944387343090153,6950042836070026,6951328350531140,6954627131536639,6955235010282518,6961198551508258,6963399508842551,6964776717426073,6965448565795276,6970137136959083,6971415472302258,6980356435772064,6983032428515932,6983905106019079,6991368739947138,6991885693679000,6993725674239650,6994584292825153,6999058942917503,7007532480563596,7008507801431963,7011275771988356,7018859141554709,7019088386679092,7019624772949285,7022741127686584,7025326594549317,7025799411207679,7030060529300225,7031772499931733,7037154318983119,7041504917268351,7042374558551250,7043157224234039,7044645651887134,7045548327228171,7047279133387038,7054773663669154,7054933002132618,7055731962385461,7057287322824161,7058626006919929,7062228052760111,7065080858036469,7069683460537958,7074209563482041,7086826025008689,7090896606328140,7096493400235778,7097698763683462,7097982268448938,7100127827759694,7102557499748570,7108011574109446,7108335675989671,7111468473911939,7111687196809432,7111832115308462,7122718270220456,7125599334933051,7125763879387488,7134057416059070,7134557189863497,7138121475122052,7139029941011677,7140513526017755,7142812917716907,7143345629642285,7145621158653174,7147071917970537,7148523205893352,7150391576884678,7150915613739412,7150988292258688,7152423253511739,7152481905628952,7152490782286339,7160114929059642,7164612299246813,7174977383833947,7175768447348500,7178819666476298,7182785050527053,7186694598573267,7188125026725330,7198990708082266,7201151879148840,7206974867385239,7212735781018353,7217274132362756,7224824748124591,7236573087867719,7238065098296019,7249591809459710,7255379223436545,7256678309194713,7257844940671035,7260488126875833,7261171903189440,7267837747945093,7268722269309149,7269251100786232,7270289414454107,7276915686116159,7282259720538393,7283784622508375,7285043915653487,7290952826871742,7292101059575933,7293761799411682,7294888743046916,7296255863679694,7301169641037175,7301849358563382,7301906714662456,7302930394962304,7309228508188679,7312290411020517,7316124738219967,7317989639940798,7319744837466584,7320291421498439,7328042554086459,7330294060715381,7331073957663616,7331233047325265,7333969667623578,7337053362421136,7342206483546105,7349184534839817,7357312740363518,7362754758320168,7371751852140180,7375771515721605,7375783664786108,7382459629507205,7383077508368235,7386861456197963,7387907040427701,7389915006128856,7397518658299872,7397956693298938,7399284950661603,7400851808029542,7401523671325108,7407945724084244,7408437485512364,7414345726475027,7417069518678584,7419000184885216,7420419893531727,7423457945395798,7425449587843521,7429112666732632,7429935267771342,7432385796087573,7435813289171051,7440832243653747,7440973229059224,7445203660440658,7446048065252422,7458700370002791,7461650546612748,7467495731905231,7472718861860143,7474152707936263,7477995360872968,7478188700630144,7481679232743229,7481744279296870,7481938798739754,7482662043326002,7486529910384584,7492099399725833,7495083779006904,7498159207049592,7500592125989039,7506767400648873,7506823859933989,7511828706259605,7513773800693242,7514015424146250,7515236634645968,7541974090026860,7542594253920300,7555919090956090,7578695451037014,7579170277748530,7582830808180436,7586151945959843,7590091860611184,7593036858653463,7596930657994709,7597551319878702,7599191525269273,7614155967227247,7614364418163540,7622332063117354,7624927826771245,7629305381826731,7635433959428700,7642837279442097,7643330625831816,7644963607514806,7645937351789604,7648861985795632,7650506330081166,7653984931810928,7657065952414594,7657323069325813,7667109796558607,7668987015585800,7670155958959342,7675306227260345,7679017779484744,7681849076736036,7687508776273244,7688075153822443,7700021239144576,7708811078987726,7709564284284054,7714576080691522,7720695230367425,7724346566617428,7727163473863477,7735354793486506,7736996772569111,7741454456034736,7742333991224085,7743794680733744,7744732148117482,7754210682244614,7765646790414117,7768333028102256,7768648130911937,7770031636801430,7773088107971490,7777417541977250,7780072251484194,7798289250452623,7806522555520092,7813080650150697,7818128039897035,7830514177274486,7831741960345649,7834008796555188,7851641276824133,7852643083092293,7859975369784969,7875565768653386,7879776855487758,7880833774369732,7881097369417932,7885471842784308,7886502562502933,7886837821175154,7888736973162446,7889975038215791,7892418694782251,7893246039165158,7894457989873422,7897865125253245,7898896401047194,7900972567309747,7908085722656489,7909793576725214,7913688920930948,7917032693387814,7921817351504681,7926324890578782,7927798288804122,7936239227565719,7938422229654421,7940034576084693,7942982994778119,7944811671592858,7954039268417678,7964644591510310,7970791010327651,7972333587328304,7972993493606415,7978966746634389,7980189039000788,7983396524985733,7984624616731791,7989373258656052,7990531335716967,7997026812200999,8002969395012401,8003727547839247,8003889134943137,8005778992093744,8006980963771565,8011120349372359,8011423370172002,8012458039367554,8015590027123263,8016178241584428,8022610662244461,8023224303791901,8023489038739406,8026683479303250,8035649962533421,8037431948118078,8039469057615153,8048722753693558,8050679546635172,8053787204723502,8063871821441599,8064614111184800,8067488249520669,8068491330467216,8081364696883911,8085060568578888,8086311029657097,8093351031638374,8093487647254421,8096848562986778,8100436055648182,8108364187629960,8109868257838823,8114928108294628,8121995675313755,8131003815347792,8147343670026517,8150068240798051,8153309127237054,8155562550003266,8155790848951453,8155877993880513,8168813496808531,8170053321274456,8174700422101747,8175808541258815,8180072999349585,8182211165524257,8188894793765401,8197454850816308,8205051116976998,8208523684689418,8210716198488134,8211340740642635,8216949152694617,8221026487131404,8221393860759481,8221806221377085,8223414501448770,8230783689783599,8233057191017539,8235949621782457,8238325057672457,8239598722456947,8241071342382473,8252920538885671,8253115917241165,8262810784813185,8264186057625418,8265978530460305,8267927566332521,8269446207460385,8274686383729352,8276106064174262,8279079310084122,8279257057543682,8282844964250726,8287612427137572,8287760505043119,8288636388417795,8288902553615125,8299638707062516,8301495361523906,8309500234526101,8311684294311637,8312737641793002,8313167211278806,8314076057607413,8316360151583040,8329838450993806,8331295524202759,8335751567659602,8336123691196209,8338402865626814,8341849113712200,8342876318432472,8343156918711837,8345922507199309,8348963809359067,8351163600580403,8356242559603521,8356779552077751,8362512977138448,8375215073587208,8376284783561842,8388385589402089,8389203716057530,8391010162315934,8392747499682204,8393990112952321,8398174168766896,8401218024072601,8402547557475187,8406133702792950,8410699489429037,8412348255510757,8416245698113800,8416708009217799,8417870328032379,8419669861053263,8421007651903198,8425309677336582,8426579376001191,8431330693863028,8433212673448750,8437235214082844,8440134748452702,8446362035881993,8455270152830360,8455291428051507,8457930462742340,8459849011976379,8466389492997022,8484322507406861,8487772114999627,8490749616300935,8491224579222800,8495841861579046,8502469322803830,8507226485943299,8507494254735559,8512554715732134,8514554000656014,8518707165275663,8522395377635190,8527811747390697,8527984656617572,8537305425888937,8540497233096162,8545430638987747,8549110469127067,8549730536729436,8555616071130722,8555934219876171,8557057256728627,8559518111198792,8561981020557315,8569556738117226,8569645811483752,8571213772598610,8574490558486293,8574829493013577,8575688161623490,8586675803869024,8586920073909847,8588367949042963,8589378475322988,8592719636792839,8593938696932191,8595218240922176,8595291683623522,8597093919626556,8602312139343472,8605167785773199,8606400361272069,8606766246714149,8609820623606185,8615492303448854,8617682544151165,8620731844333027,8621597223182477,8623437274865741,8626475997656958,8629305493123643,8634778268463699,8636628205819580,8643304611210236,8651693340486060,8651895443730794,8655645929919079,8657912896740711,8661284753203500,8666116986746818,8672135004446734,8673331508977686,8674168519690519,8674743339602833,8677187906624800,8681098106083171,8693700035881516,8697085629609046,8701874155075303,8703827753811658,8713514903605019,8714794323164639,8720375008989222,8724619716904056,8727124441563924,8728509939215032,8742983629288535,8753903389369366,8758935889169203,8759681252105338,8763656559558507,8765948613354123,8774943653014256,8775638981790429,8776440400859547,8776921824461761,8788191503384769,8792236167255051,8794883450934656,8795085549997089,8803676372076918,8810596909720553,8817761302189494,8820219611653104,8823329325002882,8830216415462002,8833496165631728,8833976999053191,8842527293144235,8851190324392334,8852983789814417,8858036339180402,8860694711616029,8865900624572642,8868415941791176,8873905529753771,8876315318895062,8877447482322724,8879762889299722,8892028081558415,8892345087197352,8893916302996149,8895067972389597,8895343056829752,8896207547647493,8896642208438094,8901823889468926,8904935865302824,8909776662847329,8911567967550613,8916339116956456,8918676745332607,8928374937325907,8929047976487864,8929966582346004,8936101617543857,8938418689951706,8942219134686579,8944392955197661,8944408201488380,8946108009024078,8950308525237144,8956428602294390,8956994285291343,8959793596044596,8966324216069537,8966417887811604,8967070390166182,8973364440827304,8975218507488191,8980765172157750,8986762426244934,8994292985362179,8996703708074452,8996877981324294,8999258980646863,9010011010071602,9011571667620299,9015574149017968,9017128073269983,9021227628585016,9021695091183972,9021897545031862,9023238186936517,9032637371601488,9035277176427163,9041217330214244,9042537182030035,9042925629473779,9050751863418813,9064989735451912,9068583735029459,9072485759203329,9073706760181680,9079250816205940,9082089288669246,9084657453045490,9101769655243867,9103837466421211,9109170270316171,9111644944198713,9118044581985344,9118856123030657,9124876620757904,9128242260082974,9130435814660472,9134717944696046,9135887737201829,9136319307873769,9144368155754832,9151731141254360,9152321262386432,9154454170724783,9161333633595317,9162219860511499,9162296141693803,9162530111528676,9165618308442234,9171411217904419,9171734483499084,9172741777541888,9183443480995341,9183637058839169,9185483945120408,9185713197465701,9187004297139865,9198649090421536,9204012482131496,9204750938215396,9204802189170271,9209625483051566,9209904399297806,9211776124956156,9217346605313913,9222486536017756],"md5sum":"9687eeed346e77fe8ca2c42915ea3558","abundances":[2,1,4,1,1,1,2,2,1,1,1,1,2,2,1,1,1,2,2,1,8,8,1,1,1,1,1,2,10,2,1,2,2,1,1,1,5,2,1,2,1,1,5,1,1,4,3,1,2,1,1,1,5,1,1,5,3,3,1,1,3,2,4,2,6,4,1,2,2,1,1,34,2,1,3,1,1,2,2,1,1,1,1,2,2,4,2,3,2,1,1,1,1,2,2,2,1,2,1,1,1,1,3,1,2,3,1,7,2,1,4,1,2,2,2,1,11,1,1,1,1,4,1,1,2,2,4,1,2,1,1,1,6,2,1,1,1,4,1,4,2,1,1,1,2,3,2,6,1,2,1,3,1,5,8,4,1,3,2,4,2,1,1,1,1,1,2,4,2,1,3,1,1,1,1,1,3,6,2,2,4,2,4,1,2,2,2,3,1,3,1,1,1,2,2,2,2,1,7,2,1,2,1,5,1,1,2,2,3,1,1,2,4,1,6,6,1,2,1,1,1,2,2,2,3,3,1,1,1,1,2,1,2,1,2,3,4,1,1,2,1,3,4,1,1,1,1,1,1,1,1,3,1,6,1,3,2,1,1,2,1,1,1,1,1,1,3,1,3,1,1,2,3,1,1,3,1,1,1,2,1,2,1,1,3,1,5,1,6,1,2,1,1,3,1,2,2,2,1,5,1,1,1,2,1,2,2,3,1,5,2,4,1,1,3,3,1,1,2,2,2,6,1,1,1,2,1,1,1,2,1,2,1,1,1,1,2,2,2,3,4,1,3,2,1,1,6,2,2,1,1,1,1,1,1,1,2,3,1,1,1,1,1,1,1,1,2,1,1,1,2,1,3,2,1,2,3,1,2,3,2,2,2,2,1,4,2,4,1,2,1,2,2,2,1,1,4,5,1,1,1,1,2,4,5,1,4,2,1,1,1,2,1,3,1,6,1,1,1,1,1,1,2,1,1,1,4,1,1,1,1,2,1,1,2,2,6,2,2,1,1,2,1,2,1,1,2,1,1,2,1,2,2,2,1,1,1,1,2,4,1,1,2,7,1,2,1,1,1,6,2,4,2,1,1,2,1,1,2,1,3,1,2,2,1,1,1,1,2,2,1,3,2,1,2,3,1,1,1,2,1,2,8,2,2,1,3,1,1,13,2,2,2,3,1,2,8,2,4,2,1,1,2,1,1,1,2,1,2,2,1,1,1,1,1,1,2,1,2,1,1,1,1,1,1,2,1,1,6,4,3,2,2,9,2,2,4,3,2,1,1,2,1,8,4,2,4,1,2,2,1,1,1,1,2,1,6,1,1,2,2,3,4,2,2,1,1,2,1,1,2,1,2,1,1,3,1,1,1,2,2,2,3,2,1,2,3,1,2,1,2,1,3,1,1,5,2,1,1,2,1,1,1,4,1,1,4,1,2,4,1,1,1,2,1,1,1,4,2,1,2,1,1,2,17,2,1,1,1,1,3,2,3,2,1,1,1,1,5,1,1,3,14,1,1,1,2,1,1,2,2,1,1,1,1,1,2,8,1,1,1,1,1,5,2,1,29,2,2,3,1,2,2,5,4,3,1,1,2,1,1,2,1,2,2,2,1,1,2,2,2,2,2,1,2,2,1,1,1,2,2,1,2,1,1,2,2,3,2,1,1,1,2,4,3,2,1,1,1,2,2,3,2,1,1,2,2,2,2,4,1,2,1,3,2,1,1,1,15,1,1,1,1,2,2,1,1,1,3,1,3,1,2,2,6,1,1,2,3,1,1,2,1,4,1,2,5,2,1,1,2,2,1,1,6,1,1,2,1,2,2,1,2,2,1,2,1,3,1,4,2,2,8,2,10,1,2,4,1,5,2,1,1,1,2,1,1,2,1,2,2,1,2,1,4,1,2,1,2,2,3,4,3,5,2,1,1,2,1,1,1,1,3,1,1,2,1,2,4,1,10,2,4,2,2,2,1,1,1,2,6,1,4,2,1,6,1,1,3,1,1,1,3,1,2,1,1,2,1,1,9,3,1,2,1,1,2,3,2,2,4,4,1,1,2,1,3,1,1,1,2,1,1,1,2,2,1,1,2,2,1,1,1,1,3,23,2,1,3,2,2,2,1,4,1,1,4,1,1,4,2,5,1,1,1,1,4,1,1,2,9,4,1,1,1,1,1,5,1,1,1,2,2,1,1,2,3,2,2,1,1,1,4,10,4,2,1,2,1,5,4,2,1,1,2,2,2,1,2,1,2,6,3,2,2,1,1,1,3,1,2,1,2,5,1,1,1,1,6,1,1,1,2,1,2,2,6,1,4,4,1,1,2,2,1,2,1,3,1,1,1,7,1,1,1,2,1,5,1,1,3,2,1,1,6,1,2,2,1,1,1,1,1,1,1,1,1,2,2,1,1,2,1,1,1,2,2,1,2,1,1,3,2,1,2,3,1,2,2,1,1,3,1,2,2,4,1,1,1,3,1,1,1,7,4,3,1,2,2,1,1,1,2,1,2,4,5,3,1,7,3,1,2,1,2,1,4,1,1,1,1,1,2,1,1,1,1,1,6,2,8,1,1,2,1,1,4,3,2,1,1,2,1,1,5,1,1,4,2,1,1,2,1,1,1,17,2,2,1,2,1,3,2,2,2,2,2,1,2,2,2,1,1,19,1,3,1,1,1,1,5,1,1,2,1,1,1,1,2,4,4,1,3,2,2,6,4,1,1,1,1,1,2,4,1,1,4,2,2,1,3,5,2,1,2,2,1,1,2,3,2,4,1,1,1,2,1,3,4,2,1,2,1,2,1,3,4,2,1,1,1,2,2,1,2,1,2,2,1,2,1,2,2,3,1,1,1,2,16,4,1,2,2,2,1,3,2,4,1,2,1,2,1,1,1,1,1,1,3,1,11,4,1,1,1,4,5,4,1,2,4,1,2,1,1,4,1,1,1,1,1,1,1,2,1,2,1,1,1,1,1,1,1,2,1,2,2,3,1,1,1,1,2,1,2,1,1,1,5,2,1,2,1,2,3,2,2,1,7,1,2,3,1,1,1,1,1,4,2,1,4,3,1,1,1,2,2,2,2,1,2,1,1,1,1,1,4,5,2,5,4,1,3,2,2,1,1,2,1,2,2,2,2,1,2,2,2,1,1,1,2,2,1,1,3,3,2,1,1,2,1,1,1,2,2,1,2,4,5,2,1,1,2,4,5,1,2,1,4,1,2,2,2,1,2,1,1,1,1,3,2,1,1,3,2,5,1,3,1,2,2,3,3,3,1,1,1,1,3,2,2,1,1,2,1,2,3,4,2,1,1,3,1,3,1,1,1,2,1,1,2,1,3,2,2,1,1,1,1,2,1,1,1,4,4,4,3,2,1,2,1,1,4,3,1,1,1,1,2,2,2,1,8,2,1,1,1,1,1,1,1,1,1,2,2,2,4,2,1,2,1,1,1,1,2,3,1,1,3,1,1,2,1,1,1,1,1,1,1,1,1,2,2,1,4,1,1,4,1,6,2,1,3,1,1,5,1,1,2,1,1,1,2,2,1,2,1,1,4,1,1,1,7,1,1,1,1,3,2,2,1,1,1,5,2,2,2,4,1,8,1,2,1,1,2,1,4,2,2,1,1,8,1,1,1,1,1,15,1,1,2,1,1,4,1,1,1,1,1,1,1,2,1,4,2,1,1,4,4,1,2,1,1,2,10,2,1,1,2,3,1,3,1,1,2,2,1,1,2,1,1,1,2,1,2,2,1,1,1,1,2,1,1,1,2,1,2,2,1,2,1,6,8,6,1,1,1,5,4,3,2,4,1,1,3,2,1,3,1,1,2,1,1,1,1,3,1,1,1,1,1,2,1,1,1,1,1,2,2,1,2,4,5,1,2,1,2,1,2,1,1,13,1,6,1,1,2,1,2,3,1,2,2,1,1,2,2,2,1,1,2,1,4,2,1,2,1,1,1,4,3,2,2,1,1,1,2,6,1,2,2,3,5,1,2,2,1,1,4,2,4,3,6,1,2,1,2,1,1,1,1,8,2,2,2,1,1,1,1,1,2,1,4,1,2,2,2,2,2,1,2,3,1,1,4,1,1,1,1,3,2,1,1,4,6,1,3,4,2,6,2,5,4,1,1,4,1,1,1,1,2,2,4,2,2,1,2,2,3,2,8,4,2,1,2,1,1,1,2,2,1,2,2,8,1,2,3,1,2,2,1,1,1,1,2,1,1,1,2,1,8,1,1,2,3,5,3,1,2,2,2,4,1,1,2,1,1,11,3,1,1,1,7,1,1,1,1,2,1,1,1,4,2,1,1,2,2,6,2,2,1,1,1,2,4,9,2,1,1,16,1,2,3,2,1,3,1,1,1,6,2,1,2,2,1,4,2,1,1,6,2,2,2,1,1,2,1,4,3,1,4,1,1,1,14,1,4,2,2,1,5,1,2,1,2,2,2,688,2,2,1,1,1,2,1,2,1,4,2,1,1,2,2,2,4,2,2,1,2,4,1,3,1,2,2,2,1,4,9,4,2,1,4,2,3,2,2,1,1,1,4,1,2,1,1,1,1,2,2,1,1,6,3,2,1,1,1,2,1,1,1,1,2,4,1,7,1,13,2,2,2,4,2,1,2,1,1,1,2,1,2,2,1,2,2,7,2,1,1,3,2,3,2,1,2,2,1,1,1,4,1,4,35,2,2,2,2,1,4,1,3,6,1,2,4,2,1,2,1,1,2,2,2,3,1,2,2,1,1,2,3,1,1,1,8,3,2,6,1,1,1,2,1,2,3,1,17,3,4,2,2,3,1,2,1,2,1,1,2,7,1,1,1,2,1,201,1,1,2,1,1,3,3,1,1,2,1,4,2,4,2,1,2,1,1,1,1,1,1,2,1,1,2,2,1,1,2,8,1,1,2,1,1,2,1,1,1,2,2,1,1,1,2,1,3,2,2,1,1,1,3,1,2,4,1,2,1,1,1,1,2,2,1,1,2,1,1,1,3,1,2,2,1,3,6,1,3,2,1,1,2,9,2,1,1,4,4,2,1,12,1,5,2,2,1,2,1,1,2,1,1,1,1,1,2,3,1,4,1,1,1,3,1,2,2,1,1,3,2,1,2,4,1,1,1,1,1,5,2,3,9,1,8,1,2,5,3,2,2,2,1,1,1,1,1,2,2,1,3,2,1,2,2,1,1,1,5,4,11,1,3,1,1,1,1,1,3,4,1,1,2,2,2,1,2,3,1,2,4,2,2,3,1,2,1,1,2,1,1,1,1,1,1,1,1,1,1,1,1,14,2,6,1,2,1,4,2,1,2,2,1,1,1,2,1,4,1,1,1,1,2,1,2,2,2,4,2,1,1,1,2,1,1,2,2,3,1,4,5,2,2,1,1,1,1,1,1,2,2,1,2,3,1,1,8,2,2,2,1,14,3,4,1,4,1,1,2,2,2,2,1,1,1,1,2,1,1,1,2,3,1,1,1,2,2,2,1,2,2,1,2,1,1,2,1,1,2,3,1,2,2,3,2,2,7,1,1,2,1,2,3,2,2,35,1,2,3,1,1,2,1,6,11,3,2,2,2,1,5,2,1,2,1,1,1,2,2,5,1,2,1,1,1,1,1,1,1,2,1,6,1,3,2,2,2,2,1,1,1,3,1,1,2,2,1,1,2],"molecule":"dna"}],"version":0.4}] \ No newline at end of file diff --git a/tests/test-data/tax/test1_x_gtdbrs202_genbank_euks.gather.csv b/tests/test-data/tax/test1_x_gtdbrs202_genbank_euks.gather.csv index 62af0c7491..05fce59e73 100644 --- a/tests/test-data/tax/test1_x_gtdbrs202_genbank_euks.gather.csv +++ b/tests/test-data/tax/test1_x_gtdbrs202_genbank_euks.gather.csv @@ -1,7 +1,7 @@ -intersect_bp,f_orig_query,f_match,f_unique_to_query,f_unique_weighted,average_abund,median_abund,std_abund,name,filename,md5,f_match_orig,unique_intersect_bp,gather_result_rank,remaining_bp,query_filename,query_name,query_md5,query_bp -442000,0.08815317112086159,0.08438335242458954,0.08815317112086159,0.05815279361459521,1.6153846153846154,1.0,1.1059438185997785,"GCF_001881345.1 Escherichia coli strain=SF-596, ASM188134v1",/group/ctbrowngrp/gtdb/databases/ctb/gtdb-rs202.genomic.k31.sbt.zip,683df1ec13872b4b98d59e98b355b52c,0.042779713511420826,442000,0,4572000,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,multtest,9687eeed,5014000 -390000,0.07778220981252493,0.10416666666666667,0.07778220981252493,0.050496823586903404,1.5897435897435896,1.0,0.8804995294906566,"GCF_009494285.1 Prevotella copri strain=iAK1218, ASM949428v1",/group/ctbrowngrp/gtdb/databases/ctb/gtdb-rs202.genomic.k31.sbt.zip,1266c86141e3a5603da61f57dd863ed0,0.052236806857755155,390000,1,4182000,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,multtest,9687eeed,5014000 -206000,0.041084962106102914,0.007403148134837921,0.041084962106102914,0.2215344518651246,13.20388349514563,3.0,69.69466823965065,"GCA_002754635.1 Plasmodium vivax strain=CMB-1, CMB-1_v2",/home/irber/sourmash_databases/outputs/sbt/genbank-protozoa-x1e6-k31.sbt.zip,8125e7913e0d0b88deb63c9ad28f827c,0.0037419167332703625,206000,2,3976000,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,multtest,9687eeed,5014000 -138000,0.027522935779816515,0.024722321748477247,0.027522935779816515,0.015637726014008795,1.391304347826087,1.0,0.5702120455914782,"GCF_013368705.1 Bacteroides vulgatus strain=B33, ASM1336870v1",/group/ctbrowngrp/gtdb/databases/ctb/gtdb-rs202.genomic.k31.sbt.zip,7d5f4ba1d01c8c3f7a520d19faded7cb,0.012648945921173235,138000,3,3838000,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,multtest,9687eeed,5014000 -338000,0.06741124850418827,0.013789581205311542,0.010769844435580374,0.006515719172503665,1.4814814814814814,1.0,0.738886568268889,"GCF_003471795.1 Prevotella copri strain=AM16-54, ASM347179v1",/group/ctbrowngrp/gtdb/databases/ctb/gtdb-rs202.genomic.k31.sbt.zip,0ebd36ff45fc2810808789667f4aad84,0.04337782340862423,54000,4,3784000,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,multtest,9687eeed,5014000 -110000,0.021938571998404467,0.000842978957948319,0.010370961308336658,0.023293696041700604,5.5,2.5,7.417494911978758,"GCA_000256725.2 Toxoplasma gondii TgCatPRC2 strain=TgCatPRC2, TGCATPRC2 v2",/home/irber/sourmash_databases/outputs/sbt/genbank-protozoa-x1e6-k31.sbt.zip,2a3b1804cf5ea5fe75dde3e153294548,0.0008909768346023004,52000,5,3732000,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,multtest,9687eeed,5014000 +intersect_bp,f_orig_query,f_match,f_unique_to_query,f_unique_weighted,average_abund,median_abund,std_abund,name,filename,md5,f_match_orig,unique_intersect_bp,gather_result_rank,remaining_bp,query_filename,query_name,query_md5,query_bp,ksize,scaled +442000,0.08815317112086159,0.08438335242458954,0.08815317112086159,0.05815279361459521,1.6153846153846154,1.0,1.1059438185997785,"GCF_001881345.1 Escherichia coli strain=SF-596, ASM188134v1",/group/ctbrowngrp/gtdb/databases/ctb/gtdb-rs202.genomic.k31.sbt.zip,683df1ec13872b4b98d59e98b355b52c,0.042779713511420826,442000,0,4572000,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,multtest,9687eeed,5014000,31,1000 +390000,0.07778220981252493,0.10416666666666667,0.07778220981252493,0.050496823586903404,1.5897435897435896,1.0,0.8804995294906566,"GCF_009494285.1 Prevotella copri strain=iAK1218, ASM949428v1",/group/ctbrowngrp/gtdb/databases/ctb/gtdb-rs202.genomic.k31.sbt.zip,1266c86141e3a5603da61f57dd863ed0,0.052236806857755155,390000,1,4182000,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,multtest,9687eeed,5014000,31,1000 +206000,0.041084962106102914,0.007403148134837921,0.041084962106102914,0.2215344518651246,13.20388349514563,3.0,69.69466823965065,"GCA_002754635.1 Plasmodium vivax strain=CMB-1, CMB-1_v2",/home/irber/sourmash_databases/outputs/sbt/genbank-protozoa-x1e6-k31.sbt.zip,8125e7913e0d0b88deb63c9ad28f827c,0.0037419167332703625,206000,2,3976000,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,multtest,9687eeed,5014000,31,1000 +138000,0.027522935779816515,0.024722321748477247,0.027522935779816515,0.015637726014008795,1.391304347826087,1.0,0.5702120455914782,"GCF_013368705.1 Bacteroides vulgatus strain=B33, ASM1336870v1",/group/ctbrowngrp/gtdb/databases/ctb/gtdb-rs202.genomic.k31.sbt.zip,7d5f4ba1d01c8c3f7a520d19faded7cb,0.012648945921173235,138000,3,3838000,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,multtest,9687eeed,5014000,31,1000 +338000,0.06741124850418827,0.013789581205311542,0.010769844435580374,0.006515719172503665,1.4814814814814814,1.0,0.738886568268889,"GCF_003471795.1 Prevotella copri strain=AM16-54, ASM347179v1",/group/ctbrowngrp/gtdb/databases/ctb/gtdb-rs202.genomic.k31.sbt.zip,0ebd36ff45fc2810808789667f4aad84,0.04337782340862423,54000,4,3784000,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,multtest,9687eeed,5014000,31,1000 +110000,0.021938571998404467,0.000842978957948319,0.010370961308336658,0.023293696041700604,5.5,2.5,7.417494911978758,"GCA_000256725.2 Toxoplasma gondii TgCatPRC2 strain=TgCatPRC2, TGCATPRC2 v2",/home/irber/sourmash_databases/outputs/sbt/genbank-protozoa-x1e6-k31.sbt.zip,2a3b1804cf5ea5fe75dde3e153294548,0.0008909768346023004,52000,5,3732000,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,multtest,9687eeed,5014000,31,1000 diff --git a/tests/test_api.py b/tests/test_api.py index 73f9ffc7a4..ccaf321df6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -69,7 +69,7 @@ def test_load_fasta_as_signature(): # try loading a fasta file - should fail with informative exception testfile = utils.get_test_data('short.fa') - with pytest.raises(ValueError) as exc: + with pytest.raises(Exception) as exc: idx = sourmash.load_file_as_index(testfile) print(exc.value) diff --git a/tests/test_cmd_signature.py b/tests/test_cmd_signature.py index 42f24593da..680924568a 100644 --- a/tests/test_cmd_signature.py +++ b/tests/test_cmd_signature.py @@ -5,6 +5,7 @@ import shutil import os import glob +import gzip import pytest import screed @@ -18,9 +19,14 @@ ## command line tests -def _write_file(runtmp, basename, lines): +def _write_file(runtmp, basename, lines, *, gz=False): loc = runtmp.output(basename) - with open(loc, 'wt') as fp: + if gz: + xopen = gzip.open + else: + xopen = open + + with xopen(loc, 'wt') as fp: fp.write("\n".join(lines)) return loc @@ -39,6 +45,15 @@ def test_run_sourmash_sig_cmd(): assert status != 0 # no args provided, ok ;) +def test_run_cat_via_parse_args(): + # run a command ('sourmash.sig.cat') with args constructed via parse_args + import sourmash.sig, sourmash.cli + sig47 = utils.get_test_data('47.fa.sig') + + args = sourmash.cli.parse_args(['sig', 'cat', sig47]) + sourmash.sig.cat(args) + + def test_sig_merge_1_use_full_signature_in_cmd(runtmp): c = runtmp @@ -89,6 +104,36 @@ def test_sig_merge_1_fromfile_picklist(runtmp): assert actual_merge_sig.minhash == test_merge_sig.minhash +def test_sig_merge_1_fromfile_picklist_gz(runtmp): + # test with --from-file and gzipped picklist + c = runtmp + + # merge of 47 & 63 should be union of mins + sig47 = utils.get_test_data('47.fa.sig') + sig63 = utils.get_test_data('63.fa.sig') + sig47and63 = utils.get_test_data('47+63.fa.sig') + + from_file = _write_file(runtmp, 'list.txt', [sig47, sig63]) + picklist = _write_file(runtmp, 'pl.csv', + ['md5short', '09a08691', '38729c63'], + gz=True) + + c.run_sourmash('signature', 'merge', '--from-file', from_file, + '--picklist', f'{picklist}:md5short:md5short') + + # stdout should be new signature + out = c.last_result.out + + test_merge_sig = sourmash.load_one_signature(sig47and63) + actual_merge_sig = sourmash.load_one_signature(out) + + print(test_merge_sig.minhash) + print(actual_merge_sig.minhash) + print(out) + + assert actual_merge_sig.minhash == test_merge_sig.minhash + + @utils.in_tempdir def test_sig_merge_1(c): # merge of 47 & 63 should be union of mins @@ -360,6 +405,17 @@ def test_sig_merge_flatten_2(c): assert actual_merge_sig.minhash == test_merge_sig.minhash +def test_sig_intersect_0(runtmp): + # should print usage when no arguments + c = runtmp + + with pytest.raises(SourmashCommandFailed): + c.run_sourmash('sig', 'intersect') + + err = c.last_result.err + assert "no signatures provided to intersect!?" in err + + def test_sig_intersect_1(runtmp): c = runtmp @@ -1409,6 +1465,42 @@ def test_sig_split_3_multisig(c): assert os.path.exists(c.output(filename)) +def test_sig_split_3_multisig_sig_gz(runtmp): + # split 47 and 47+63-multisig.sig with a .sig.gz extension + c = runtmp + + sig47 = utils.get_test_data('47.fa.sig') + multisig = utils.get_test_data('47+63-multisig.sig') + c.run_sourmash('sig', 'split', sig47, multisig, '-E', '.sig.gz') + + outlist = ['57e2b22f.k=31.scaled=1000.DNA.dup=0.none.sig.gz', + 'bde81a41.k=31.scaled=1000.DNA.dup=0.none.sig.gz', + 'f033bbd8.k=31.scaled=1000.DNA.dup=0.none.sig.gz', + '87a9aec4.k=31.scaled=1000.DNA.dup=0.none.sig.gz', + '837bf2a7.k=31.scaled=1000.DNA.dup=0.none.sig.gz', + '485c3377.k=31.scaled=1000.DNA.dup=0.none.sig.gz'] + for filename in outlist: + assert os.path.exists(c.output(filename)) + + +def test_sig_split_3_multisig_zip(runtmp): + # split 47 and 47+63-multisig.sig with a .zip extension + c = runtmp + + sig47 = utils.get_test_data('47.fa.sig') + multisig = utils.get_test_data('47+63-multisig.sig') + c.run_sourmash('sig', 'split', sig47, multisig, '-E', '.zip') + + outlist = ['57e2b22f.k=31.scaled=1000.DNA.dup=0.none.zip', + 'bde81a41.k=31.scaled=1000.DNA.dup=0.none.zip', + 'f033bbd8.k=31.scaled=1000.DNA.dup=0.none.zip', + '87a9aec4.k=31.scaled=1000.DNA.dup=0.none.zip', + '837bf2a7.k=31.scaled=1000.DNA.dup=0.none.zip', + '485c3377.k=31.scaled=1000.DNA.dup=0.none.zip'] + for filename in outlist: + assert os.path.exists(c.output(filename)) + + @utils.in_tempdir def test_sig_split_4_sbt_prot(c): # split sbt @@ -3264,6 +3356,7 @@ def test_sig_describe_empty(c): assert len(ss) == 1 ss = ss[0] + ss = ss.to_mutable() ss.name = '' ss.filename = '' @@ -3326,6 +3419,29 @@ def test_sig_describe_2_csv(runtmp): assert n == 2 +def test_sig_describe_2_csv_gz(runtmp): + # output info in CSV spreadsheet, gzipped + c = runtmp + + sig47 = utils.get_test_data('47.fa.sig') + sig63 = utils.get_test_data('63.fa.sig') + c.run_sourmash('sig', 'describe', sig47, sig63, '--csv', 'out.csv.gz') + + expected_md5 = ['09a08691ce52952152f0e866a59f6261', + '38729c6374925585db28916b82a6f513'] + + with gzip.open(c.output('out.csv.gz'), 'rt', newline="") as fp: + r = csv.DictReader(fp) + + n = 0 + + for row, md5 in zip(r, expected_md5): + assert row['md5'] == md5 + n += 1 + + assert n == 2 + + def test_sig_describe_2_csv_abund(runtmp): # output info in CSV spreadsheet, for abund sig c = runtmp @@ -3537,7 +3653,7 @@ def test_import_mash_csv_to_sig(runtmp): print("RUNTEMP", runtmp) - assert '1 matches:' in runtmp.last_result.out + assert '1 matches' in runtmp.last_result.out assert '100.0% short.fa' in runtmp.last_result.out @@ -3556,6 +3672,22 @@ def test_sig_manifest_1_zipfile(runtmp): assert '120d311cc785cc9d0df9dc0646b2b857' in md5_list +def test_sig_manifest_1_zipfile_csv_gz(runtmp): + # make a gzipped manifest from a .zip file + protzip = utils.get_test_data('prot/protein.zip') + runtmp.sourmash('sig', 'manifest', protzip, + '-o', 'SOURMASH-MANIFEST.csv.gz') + + manifest_fn = runtmp.output('SOURMASH-MANIFEST.csv.gz') + with gzip.open(manifest_fn, "rt", newline='') as csvfp: + manifest = CollectionManifest.load_from_csv(csvfp) + + assert len(manifest) == 2 + md5_list = [ row['md5'] for row in manifest.rows ] + assert '16869d2c8a1d29d1c8e56f5c561e585e' in md5_list + assert '120d311cc785cc9d0df9dc0646b2b857' in md5_list + + def test_sig_manifest_1_zipfile_already_exists(runtmp): # make a manifest from a .zip file; f protzip = utils.get_test_data('prot/protein.zip') @@ -4381,6 +4513,65 @@ def test_sig_check_1(runtmp): assert 31 in ksizes +def test_sig_check_1_mf_csv_gz(runtmp): + # basic check functionality, with gzipped manifest output + sigfiles = glob.glob(utils.get_test_data('gather/GCF*.sig')) + picklist = utils.get_test_data('gather/salmonella-picklist.csv') + + runtmp.sourmash('sig', 'check', *sigfiles, + "--picklist", f"{picklist}::manifest", + "-m", "mf.csv.gz") + + out_mf = runtmp.output('mf.csv.gz') + assert os.path.exists(out_mf) + + # all should match. + with gzip.open(out_mf, "rt", newline='') as fp: + mf = CollectionManifest.load_from_csv(fp) + assert len(mf) == 24 + + idx = sourmash.load_file_as_index(out_mf) + siglist = list(idx.signatures()) + assert len(siglist) == 24 + ksizes = set([ ss.minhash.ksize for ss in siglist ]) + assert len(ksizes) == 3 + assert 11 in ksizes + assert 21 in ksizes + assert 31 in ksizes + + +def test_sig_check_1_gz(runtmp): + # basic check functionality with gzipped picklist + sigfiles = glob.glob(utils.get_test_data('gather/GCF*.sig')) + picklist = utils.get_test_data('gather/salmonella-picklist.csv') + picklist_gz = runtmp.output('salmonella.csv.gz') + + with gzip.open(picklist_gz, "w") as outfp: + with open(picklist, "rb") as infp: + outfp.write(infp.read()) + + runtmp.sourmash('sig', 'check', *sigfiles, + "--picklist", "salmonella.csv.gz::manifest", + "-m", "mf.csv") + + out_mf = runtmp.output('mf.csv') + assert os.path.exists(out_mf) + + # all should match. + with open(out_mf, newline='') as fp: + mf = CollectionManifest.load_from_csv(fp) + assert len(mf) == 24 + + idx = sourmash.load_file_as_index(out_mf) + siglist = list(idx.signatures()) + assert len(siglist) == 24 + ksizes = set([ ss.minhash.ksize for ss in siglist ]) + assert len(ksizes) == 3 + assert 11 in ksizes + assert 21 in ksizes + assert 31 in ksizes + + def test_sig_check_1_nofail(runtmp): # basic check functionality with --fail-if-missing sigfiles = glob.glob(utils.get_test_data('gather/GCF*.sig')) diff --git a/tests/test_cmd_signature_collect.py b/tests/test_cmd_signature_collect.py index 1f346eb464..61f703080f 100644 --- a/tests/test_cmd_signature_collect.py +++ b/tests/test_cmd_signature_collect.py @@ -4,6 +4,7 @@ import pytest import shutil import os.path +import gzip import sourmash from sourmash.manifest import BaseCollectionManifest @@ -44,6 +45,63 @@ def test_sig_collect_1_zipfile(runtmp, manifest_db_format): assert '120d311cc785cc9d0df9dc0646b2b857' in md5_list +def test_sig_collect_1_zipfile_csv_gz(runtmp): + # collect a manifest from a .zip file, save to csv.gz + protzip = utils.get_test_data('prot/protein.zip') + + runtmp.sourmash('sig', 'collect', protzip, '-o', 'mf.csv.gz', + '-F', 'csv') + + manifest_fn = runtmp.output('mf.csv.gz') + + # gzip, yes? + print('XXX', manifest_fn) + with gzip.open(manifest_fn, 'rt', newline='') as fp: + fp.read() + + manifest = BaseCollectionManifest.load_from_filename(manifest_fn) + + assert len(manifest) == 2 + md5_list = [ row['md5'] for row in manifest.rows ] + assert '16869d2c8a1d29d1c8e56f5c561e585e' in md5_list + assert '120d311cc785cc9d0df9dc0646b2b857' in md5_list + + +def test_sig_collect_1_zipfile_csv_gz_roundtrip(runtmp): + # collect a manifest from a .zip file, save to csv.gz; then load again + protzip = utils.get_test_data('prot/protein.zip') + + runtmp.sourmash('sig', 'collect', protzip, '-o', 'mf.csv.gz', + '-F', 'csv') + + manifest_fn = runtmp.output('mf.csv.gz') + + # gzip, yes? + print('XXX', manifest_fn) + with gzip.open(manifest_fn, 'rt', newline='') as fp: + fp.read() + + manifest = BaseCollectionManifest.load_from_filename(manifest_fn) + + assert len(manifest) == 2 + md5_list = [ row['md5'] for row in manifest.rows ] + assert '16869d2c8a1d29d1c8e56f5c561e585e' in md5_list + assert '120d311cc785cc9d0df9dc0646b2b857' in md5_list + + # can we read a csv.gz? + runtmp.sourmash('sig', 'collect', 'mf.csv.gz', '-o', 'mf2.csv', + '-F', 'csv') + + manifest_fn2 = runtmp.output('mf2.csv') + manifest2 = BaseCollectionManifest.load_from_filename(manifest_fn2) + + assert len(manifest2) == 2 + md5_list = [ row['md5'] for row in manifest2.rows ] + assert '16869d2c8a1d29d1c8e56f5c561e585e' in md5_list + assert '120d311cc785cc9d0df9dc0646b2b857' in md5_list + + + def test_sig_collect_2_exists_fail(runtmp, manifest_db_format): # collect a manifest from two .zip files protzip = utils.get_test_data('prot/protein.zip') @@ -127,7 +185,7 @@ def test_sig_collect_2_exists_csv_merge_sql(runtmp): ext = 'csv' - # save as sql... + # save as csv... runtmp.sourmash('sig', 'collect', protzip, '-o', f'mf.{ext}', '-F', 'csv') diff --git a/tests/test_cmd_signature_grep.py b/tests/test_cmd_signature_grep.py index d9788cbe0f..7979c98847 100644 --- a/tests/test_cmd_signature_grep.py +++ b/tests/test_cmd_signature_grep.py @@ -4,6 +4,7 @@ import shutil import os import csv +import gzip import pytest @@ -269,6 +270,38 @@ def test_grep_6_zip_manifest_csv(runtmp): assert ss.md5sum() == '38729c6374925585db28916b82a6f513' +def test_grep_6_zip_manifest_csv_gz(runtmp): + # do --csv and use result as picklist + allzip = utils.get_test_data('prot/all.zip') + + runtmp.run_sourmash('sig', 'grep', '--dna', 'OS223', allzip, + '--csv', 'match.csv.gz') + + out = runtmp.last_result.out + ss = load_signatures(out) + ss = list(ss) + assert len(ss) == 1 + ss = ss[0] + assert 'Shewanella baltica OS223' in ss.name + assert ss.md5sum() == '38729c6374925585db28916b82a6f513' + + # check that match.csv.gz is a gzip file + with gzip.open(runtmp.output('match.csv.gz'), 'rt', newline='') as fp: + fp.read() + + # now run cat with picklist + runtmp.run_sourmash('sig', 'cat', allzip, + '--picklist', 'match.csv.gz::manifest') + + out = runtmp.last_result.out + ss = load_signatures(out) + ss = list(ss) + assert len(ss) == 1 + ss = ss[0] + assert 'Shewanella baltica OS223' in ss.name + assert ss.md5sum() == '38729c6374925585db28916b82a6f513' + + def test_sig_grep_7_lca(runtmp): # extract 47 from an LCA database, with --no-require-manifest allzip = utils.get_test_data('lca/47+63.lca.json') @@ -282,6 +315,8 @@ def test_sig_grep_7_lca(runtmp): ss47 = sourmash.load_file_as_signatures(sig47) ss47 = list(ss47)[0] + + ss47 = ss47.to_mutable() ss47.minhash = ss47.minhash.downsample(scaled=10000) assert ss47.minhash == match.minhash diff --git a/tests/test_compare.py b/tests/test_compare.py index 66c46e4386..bc25e98e3c 100644 --- a/tests/test_compare.py +++ b/tests/test_compare.py @@ -81,8 +81,8 @@ def test_compare_serial_jaccardANI(scaled_siglist, ignore_abundance): print(jANI) true_jaccard_ANI = np.array( - [[1., 0., 0., 0.], - [0., 1., 0.96973012, 0.99262776], + [[1., 0.978, 0., 0.], + [0.978, 1., 0.96973012, 0.99262776], [0., 0.96973012, 1., 0.97697011], [0., 0.99262776, 0.97697011, 1.]]) @@ -93,8 +93,8 @@ def test_compare_parallel_jaccardANI(scaled_siglist, ignore_abundance): jANI = compare_parallel(scaled_siglist, ignore_abundance, downsample=False, n_jobs=2, return_ani=True) true_jaccard_ANI = np.array( - [[1., 0., 0., 0.], - [0., 1., 0.96973012, 0.99262776], + [[1., 0.978, 0., 0.], + [0.978, 1., 0.96973012, 0.99262776], [0., 0.96973012, 1., 0.97697011], [0., 0.99262776, 0.97697011, 1.]]) @@ -112,32 +112,38 @@ def test_compare_serial_containmentANI(scaled_siglist): print(containment_ANI) true_containment_ANI = np.array( - [[1, 0., 0., 0.], - [0., 1., 0.97715525, 1.], + [[1, 0.966, 0., 0.], + [1, 1., 0.97715525, 1.], [0., 0.96377054, 1., 0.97678608], [0., 0.98667513, 0.97715525, 1.]]) np.testing.assert_array_almost_equal(containment_ANI, true_containment_ANI, decimal=3) + +def test_compare_serial_maxcontainmentANI(scaled_siglist): + # check max_containment ANI max_containment_ANI = compare_serial_max_containment(scaled_siglist, return_ani=True) print(max_containment_ANI) true_max_containment_ANI = np.array( - [[1., 0., 0., 0.], - [0., 1., 0.97715525, 1.], + [[1., 1., 0., 0.], + [1., 1., 0.97715525, 1.], [0., 0.97715525, 1., 0.97715525], [0., 1., 0.97715525, 1.]]) np.testing.assert_array_almost_equal(max_containment_ANI, true_max_containment_ANI, decimal=3) + +def test_compare_serial_avg_containmentANI(scaled_siglist): + # check avg_containment ANI avg_containment_ANI = compare_serial_avg_containment(scaled_siglist, return_ani=True) print(avg_containment_ANI) true_avg_containment_ANI = np.array( - [[1., 0., 0., 0.], - [0., 1., 0.97046289, 0.99333757], + [[1., 0.983, 0., 0.], + [0.983, 1., 0.97046289, 0.99333757], [0., 0.97046289, 1., 0.97697067], [0., 0.99333757, 0.97697067, 1.]]) diff --git a/tests/test_distance_utils.py b/tests/test_distance_utils.py index 02773ad81d..22067dcc68 100644 --- a/tests/test_distance_utils.py +++ b/tests/test_distance_utils.py @@ -6,7 +6,7 @@ from sourmash.distance_utils import (containment_to_distance, get_exp_probability_nothing_common, handle_seqlen_nkmers, jaccard_to_distance, ANIResult, ciANIResult, jaccardANIResult, var_n_mutated, - set_size_chernoff) + set_size_chernoff, set_size_exact_prob) def test_aniresult(): res = ANIResult(0.4, 0.1) @@ -416,4 +416,37 @@ def test_set_size_chernoff(): set_size = 10 s = 1/.01 value_from_mathematica = -1 - assert np.abs(set_size_chernoff(set_size, s,relative_error=rel_error) - value_from_mathematica) < eps + assert np.abs(set_size_chernoff(set_size, s, relative_error=rel_error) - value_from_mathematica) < eps + + +def test_set_size_exact_prob(): + # values obtained from Mathematica + # specifically: Probability[Abs[X*s - n]/n <= delta, + # X \[Distributed] BinomialDistribution[n, 1/s]] // N + set_size = 100 + scaled = 2 + relative_error = 0.05 + prob = set_size_exact_prob(set_size, scaled, relative_error=relative_error) + true_prob = 0.382701 + np.testing.assert_array_almost_equal(true_prob, prob, decimal=3) + + set_size = 200 + scaled = 5 + relative_error = 0.15 + prob = set_size_exact_prob(set_size, scaled, relative_error=relative_error) + true_prob = 0.749858 + np.testing.assert_array_almost_equal(true_prob, prob, decimal=3) + + set_size = 10 + scaled = 10 + relative_error = 0.10 + prob = set_size_exact_prob(set_size, scaled, relative_error=relative_error) + true_prob = 0.38742 + np.testing.assert_array_almost_equal(true_prob, prob, decimal=3) + + set_size = 1000 + scaled = 10 + relative_error = 0.10 + prob = set_size_exact_prob(set_size, scaled, relative_error=relative_error) + true_prob = 0.73182 + np.testing.assert_array_almost_equal(true_prob, prob, decimal=3) diff --git a/tests/test_index.py b/tests/test_index.py index 24468db9bf..af0c1da890 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -8,7 +8,6 @@ import shutil import sourmash -from sourmash import index from sourmash import load_one_signature, SourmashSignature from sourmash.index import (LinearIndex, ZipFileLinearIndex, make_jaccard_search_query, CounterGather, @@ -169,11 +168,11 @@ def test_linear_index_gather_subj_has_abundance(): linear = LinearIndex() linear.insert(ss) - results = list(linear.gather(qs, threshold=0)) - assert len(results) == 1 + result = linear.best_containment(qs, threshold=0) + assert result # note: gather returns _original_ signature, not flattened - assert results[0].signature == ss + assert result.signature == ss def test_index_search_subj_scaled_is_lower(): @@ -270,6 +269,7 @@ def test_linear_index_search_abund_downsample_query(): ss63 = sourmash.load_one_signature(sig63) # forcibly downsample ss47 for the purpose of this test :) + ss47 = ss47.to_mutable() ss47.minhash = ss63.minhash.downsample(scaled=2000) assert ss63.minhash.scaled != ss47.minhash.scaled @@ -292,6 +292,7 @@ def test_linear_index_search_abund_downsample_subj(): ss63 = sourmash.load_one_signature(sig63) # forcibly downsample ss63 for the purpose of this test :) + ss63 = ss63.to_mutable() ss63.minhash = ss63.minhash.downsample(scaled=2000) assert ss63.minhash.scaled != ss47.minhash.scaled @@ -458,22 +459,24 @@ def test_linear_gather_threshold_1(): # query with empty hashes assert not new_mh with pytest.raises(ValueError): - linear.gather(SourmashSignature(new_mh)) + linear.best_containment(SourmashSignature(new_mh)) # add one hash new_mh.add_hash(mins.pop()) assert len(new_mh) == 1 - results = linear.gather(SourmashSignature(new_mh)) - assert len(results) == 1 - containment, match_sig, name = results[0] + result = linear.best_containment(SourmashSignature(new_mh)) + assert result + + # it's a namedtuple, so we can unpack like a tuple. + containment, match_sig, name = result assert containment == 1.0 assert match_sig == sig2 assert name is None # check with a threshold -> should be no results. with pytest.raises(ValueError): - linear.gather(SourmashSignature(new_mh), threshold_bp=5000) + linear.best_containment(SourmashSignature(new_mh), threshold_bp=5000) # add three more hashes => length of 4 new_mh.add_hash(mins.pop()) @@ -481,16 +484,16 @@ def test_linear_gather_threshold_1(): new_mh.add_hash(mins.pop()) assert len(new_mh) == 4 - results = linear.gather(SourmashSignature(new_mh)) - assert len(results) == 1 - containment, match_sig, name = results[0] + result = linear.best_containment(SourmashSignature(new_mh)) + assert result + containment, match_sig, name = result assert containment == 1.0 assert match_sig == sig2 assert name is None # check with a too-high threshold -> should be no results. with pytest.raises(ValueError): - linear.gather(SourmashSignature(new_mh), threshold_bp=5000) + linear.best_containment(SourmashSignature(new_mh), threshold_bp=5000) def test_linear_gather_threshold_5(): @@ -520,17 +523,18 @@ def test_linear_gather_threshold_5(): new_mh.add_hash(mins.pop()) # should get a result with no threshold (any match at all is returned) - results = linear.gather(SourmashSignature(new_mh)) - assert len(results) == 1 - containment, match_sig, name = results[0] + result = linear.best_containment(SourmashSignature(new_mh)) + assert result + containment, match_sig, name = result assert containment == 1.0 assert match_sig == sig2 assert name == 'foo' # now, check with a threshold_bp that should be meet-able. - results = linear.gather(SourmashSignature(new_mh), threshold_bp=5000) - assert len(results) == 1 - containment, match_sig, name = results[0] + result = linear.best_containment(SourmashSignature(new_mh), + threshold_bp=5000) + assert result + containment, match_sig, name = result assert containment == 1.0 assert match_sig == sig2 assert name == 'foo' @@ -686,7 +690,7 @@ def test_zipfile_protein_command_search(runtmp): db_out = utils.get_test_data('prot/protein.zip') c.run_sourmash('search', sigfile1, db_out, '--threshold', '0.0') - assert '2 matches:' in c.last_result.out + assert '2 matches' in c.last_result.out c.run_sourmash('gather', sigfile1, db_out) assert 'found 1 matches total' in c.last_result.out @@ -701,7 +705,7 @@ def test_zipfile_hp_command_search(runtmp): db_out = utils.get_test_data('prot/hp.zip') c.run_sourmash('search', sigfile1, db_out, '--threshold', '0.0') - assert '2 matches:' in c.last_result.out + assert '2 matches' in c.last_result.out c.run_sourmash('gather', sigfile1, db_out, '--threshold', '0.0') assert 'found 1 matches total' in c.last_result.out @@ -716,7 +720,7 @@ def test_zipfile_dayhoff_command_search(runtmp): db_out = utils.get_test_data('prot/dayhoff.zip') c.run_sourmash('search', sigfile1, db_out, '--threshold', '0.0') - assert '2 matches:' in c.last_result.out + assert '2 matches' in c.last_result.out c.run_sourmash('gather', sigfile1, db_out, '--threshold', '0.0') assert 'found 1 matches total' in c.last_result.out @@ -731,7 +735,7 @@ def test_zipfile_protein_command_search_combined(runtmp): db_out = utils.get_test_data('prot/all.zip') c.run_sourmash('search', sigfile1, db_out, '--threshold', '0.0') - assert '2 matches:' in c.last_result.out + assert '2 matches' in c.last_result.out c.run_sourmash('gather', sigfile1, db_out) assert 'found 1 matches total' in c.last_result.out @@ -746,7 +750,7 @@ def test_zipfile_hp_command_search_combined(runtmp): db_out = utils.get_test_data('prot/all.zip') c.run_sourmash('search', sigfile1, db_out, '--threshold', '0.0') - assert '2 matches:' in c.last_result.out + assert '2 matches' in c.last_result.out c.run_sourmash('gather', sigfile1, db_out, '--threshold', '0.0') assert 'found 1 matches total' in c.last_result.out @@ -761,7 +765,7 @@ def test_zipfile_dayhoff_command_search_combined(runtmp): db_out = utils.get_test_data('prot/all.zip') c.run_sourmash('search', sigfile1, db_out, '--threshold', '0.0') - assert '2 matches:' in c.last_result.out + assert '2 matches' in c.last_result.out c.run_sourmash('gather', sigfile1, db_out, '--threshold', '0.0') assert 'found 1 matches total' in c.last_result.out @@ -1098,7 +1102,7 @@ def test_multi_index_search(): def test_multi_index_gather(): - # test MultiIndex.gather + # test MultiIndex.best_containment sig2 = utils.get_test_data('2.fa.sig') sig47 = utils.get_test_data('47.fa.sig') sig63 = utils.get_test_data('63.fa.sig') @@ -1116,16 +1120,16 @@ def test_multi_index_gather(): None) lidx = lidx.select(ksize=31) - matches = lidx.gather(ss2) - assert len(matches) == 1 - assert matches[0][0] == 1.0 - assert matches[0][2] == 'A' + match = lidx.best_containment(ss2) + assert match + assert match.score == 1.0 + assert match.location == 'A' - matches = lidx.gather(ss47) - assert len(matches) == 1 - assert matches[0][0] == 1.0 - assert matches[0][1] == ss47 - assert matches[0][2] == sig47 # no source override + match = lidx.best_containment(ss47) + assert match + assert match.score == 1.0 + assert match.signature == ss47 + assert match.location == sig47 # no source override def test_multi_index_signatures(): @@ -1542,546 +1546,10 @@ def is_found(ss, xx): assert not is_found(ss2, results) assert is_found(ss63, results) -### -### CounterGather tests -### - - -def _consume_all(query_mh, counter, threshold_bp=0): - results = [] - query_mh = query_mh.to_mutable() - - last_intersect_size = None - while 1: - result = counter.peek(query_mh, threshold_bp) - if not result: - break - - sr, intersect_mh = result - print(sr.signature.name, len(intersect_mh)) - if last_intersect_size: - assert len(intersect_mh) <= last_intersect_size - - last_intersect_size = len(intersect_mh) - - counter.consume(intersect_mh) - query_mh.remove_many(intersect_mh.hashes) - - results.append((sr, len(intersect_mh))) - - return results - - -def test_counter_gather_1(): - # check a contrived set of non-overlapping gather results, - # generated via CounterGather - query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) - query_mh.add_many(range(0, 20)) - query_ss = SourmashSignature(query_mh, name='query') - - match_mh_1 = query_mh.copy_and_clear() - match_mh_1.add_many(range(0, 10)) - match_ss_1 = SourmashSignature(match_mh_1, name='match1') - - match_mh_2 = query_mh.copy_and_clear() - match_mh_2.add_many(range(10, 15)) - match_ss_2 = SourmashSignature(match_mh_2, name='match2') - - match_mh_3 = query_mh.copy_and_clear() - match_mh_3.add_many(range(15, 17)) - match_ss_3 = SourmashSignature(match_mh_3, name='match3') - - # load up the counter - counter = CounterGather(query_ss.minhash) - counter.add(match_ss_1) - counter.add(match_ss_2) - counter.add(match_ss_3) - - results = _consume_all(query_ss.minhash, counter) - - expected = (['match1', 10], - ['match2', 5], - ['match3', 2],) - assert len(results) == len(expected), results - - for (sr, size), (exp_name, exp_size) in zip(results, expected): - sr_name = sr.signature.name.split()[0] - - assert sr_name == exp_name - assert size == exp_size - - -def test_counter_gather_1_b(): - # check a contrived set of somewhat-overlapping gather results, - # generated via CounterGather. Here the overlaps are structured - # so that the gather results are the same as those in - # test_counter_gather_1(), even though the overlaps themselves are - # larger. - query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) - query_mh.add_many(range(0, 20)) - query_ss = SourmashSignature(query_mh, name='query') - - match_mh_1 = query_mh.copy_and_clear() - match_mh_1.add_many(range(0, 10)) - match_ss_1 = SourmashSignature(match_mh_1, name='match1') - - match_mh_2 = query_mh.copy_and_clear() - match_mh_2.add_many(range(7, 15)) - match_ss_2 = SourmashSignature(match_mh_2, name='match2') - - match_mh_3 = query_mh.copy_and_clear() - match_mh_3.add_many(range(13, 17)) - match_ss_3 = SourmashSignature(match_mh_3, name='match3') - - # load up the counter - counter = CounterGather(query_ss.minhash) - counter.add(match_ss_1) - counter.add(match_ss_2) - counter.add(match_ss_3) - - results = _consume_all(query_ss.minhash, counter) - - expected = (['match1', 10], - ['match2', 5], - ['match3', 2],) - assert len(results) == len(expected), results - - for (sr, size), (exp_name, exp_size) in zip(results, expected): - sr_name = sr.signature.name.split()[0] - - assert sr_name == exp_name - assert size == exp_size - - -def test_counter_gather_1_c_with_threshold(): - # check a contrived set of somewhat-overlapping gather results, - # generated via CounterGather. Here the overlaps are structured - # so that the gather results are the same as those in - # test_counter_gather_1(), even though the overlaps themselves are - # larger. - # use a threshold, here. - - query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) - query_mh.add_many(range(0, 20)) - query_ss = SourmashSignature(query_mh, name='query') - - match_mh_1 = query_mh.copy_and_clear() - match_mh_1.add_many(range(0, 10)) - match_ss_1 = SourmashSignature(match_mh_1, name='match1') - - match_mh_2 = query_mh.copy_and_clear() - match_mh_2.add_many(range(7, 15)) - match_ss_2 = SourmashSignature(match_mh_2, name='match2') - - match_mh_3 = query_mh.copy_and_clear() - match_mh_3.add_many(range(13, 17)) - match_ss_3 = SourmashSignature(match_mh_3, name='match3') - - # load up the counter - counter = CounterGather(query_ss.minhash) - counter.add(match_ss_1) - counter.add(match_ss_2) - counter.add(match_ss_3) - - results = _consume_all(query_ss.minhash, counter, - threshold_bp=3) - - expected = (['match1', 10], - ['match2', 5]) - assert len(results) == len(expected), results - - for (sr, size), (exp_name, exp_size) in zip(results, expected): - sr_name = sr.signature.name.split()[0] - - assert sr_name == exp_name - assert size == exp_size - - -def test_counter_gather_1_d_diff_scaled(): - # test as above, but with different scaled. - query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) - query_mh.add_many(range(0, 20)) - query_ss = SourmashSignature(query_mh, name='query') - - match_mh_1 = query_mh.copy_and_clear().downsample(scaled=10) - match_mh_1.add_many(range(0, 10)) - match_ss_1 = SourmashSignature(match_mh_1, name='match1') - - match_mh_2 = query_mh.copy_and_clear().downsample(scaled=20) - match_mh_2.add_many(range(7, 15)) - match_ss_2 = SourmashSignature(match_mh_2, name='match2') - - match_mh_3 = query_mh.copy_and_clear().downsample(scaled=30) - match_mh_3.add_many(range(13, 17)) - match_ss_3 = SourmashSignature(match_mh_3, name='match3') - - # load up the counter - counter = CounterGather(query_ss.minhash) - counter.add(match_ss_1) - counter.add(match_ss_2) - counter.add(match_ss_3) - - results = _consume_all(query_ss.minhash, counter) - - expected = (['match1', 10], - ['match2', 5], - ['match3', 2],) - assert len(results) == len(expected), results - - for (sr, size), (exp_name, exp_size) in zip(results, expected): - sr_name = sr.signature.name.split()[0] - - assert sr_name == exp_name - assert size == exp_size - - -def test_counter_gather_1_d_diff_scaled_query(): - # test as above, but with different scaled for QUERY. - query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) - query_mh.add_many(range(0, 20)) - - match_mh_1 = query_mh.copy_and_clear().downsample(scaled=10) - match_mh_1.add_many(range(0, 10)) - match_ss_1 = SourmashSignature(match_mh_1, name='match1') - - match_mh_2 = query_mh.copy_and_clear().downsample(scaled=20) - match_mh_2.add_many(range(7, 15)) - match_ss_2 = SourmashSignature(match_mh_2, name='match2') - - match_mh_3 = query_mh.copy_and_clear().downsample(scaled=30) - match_mh_3.add_many(range(13, 17)) - match_ss_3 = SourmashSignature(match_mh_3, name='match3') - - # downsample query now - - query_ss = SourmashSignature(query_mh.downsample(scaled=100), name='query') - - # load up the counter - counter = CounterGather(query_ss.minhash) - counter.add(match_ss_1) - counter.add(match_ss_2) - counter.add(match_ss_3) - - results = _consume_all(query_ss.minhash, counter) - - expected = (['match1', 10], - ['match2', 5], - ['match3', 2],) - assert len(results) == len(expected), results - - for (sr, size), (exp_name, exp_size) in zip(results, expected): - sr_name = sr.signature.name.split()[0] - - assert sr_name == exp_name - assert size == exp_size - - -def test_counter_gather_1_e_abund_query(): - # test as above, but abund query - query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1, track_abundance=1) - query_mh.add_many(range(0, 20)) - query_ss = SourmashSignature(query_mh, name='query') - - match_mh_1 = query_mh.copy_and_clear().flatten() - match_mh_1.add_many(range(0, 10)) - match_ss_1 = SourmashSignature(match_mh_1, name='match1') - - match_mh_2 = query_mh.copy_and_clear().flatten() - match_mh_2.add_many(range(7, 15)) - match_ss_2 = SourmashSignature(match_mh_2, name='match2') - - match_mh_3 = query_mh.copy_and_clear().flatten() - match_mh_3.add_many(range(13, 17)) - match_ss_3 = SourmashSignature(match_mh_3, name='match3') - - # load up the counter - counter = CounterGather(query_ss.minhash) - counter.add(match_ss_1) - counter.add(match_ss_2) - counter.add(match_ss_3) - - # must flatten before peek! - results = _consume_all(query_ss.minhash.flatten(), counter) - - expected = (['match1', 10], - ['match2', 5], - ['match3', 2],) - assert len(results) == len(expected), results - - for (sr, size), (exp_name, exp_size) in zip(results, expected): - sr_name = sr.signature.name.split()[0] - - assert sr_name == exp_name - assert size == exp_size - - -def test_counter_gather_1_f_abund_match(): - # test as above, but abund query - query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1, track_abundance=1) - query_mh.add_many(range(0, 20)) - query_ss = SourmashSignature(query_mh.flatten(), name='query') - - match_mh_1 = query_mh.copy_and_clear() - match_mh_1.add_many(range(0, 10)) - match_ss_1 = SourmashSignature(match_mh_1, name='match1') - - match_mh_2 = query_mh.copy_and_clear() - match_mh_2.add_many(range(7, 15)) - match_ss_2 = SourmashSignature(match_mh_2, name='match2') - - match_mh_3 = query_mh.copy_and_clear() - match_mh_3.add_many(range(13, 17)) - match_ss_3 = SourmashSignature(match_mh_3, name='match3') - - # load up the counter - counter = CounterGather(query_ss.minhash) - counter.add(match_ss_1) - counter.add(match_ss_2) - counter.add(match_ss_3) - - # must flatten before peek! - results = _consume_all(query_ss.minhash.flatten(), counter) - - expected = (['match1', 10], - ['match2', 5], - ['match3', 2],) - assert len(results) == len(expected), results - - for (sr, size), (exp_name, exp_size) in zip(results, expected): - sr_name = sr.signature.name.split()[0] - - assert sr_name == exp_name - assert size == exp_size - - -def test_counter_gather_2(): - # check basic set of gather results on semi-real data, - # generated via CounterGather - testdata_combined = utils.get_test_data('gather/combined.sig') - testdata_glob = utils.get_test_data('gather/GCF*.sig') - testdata_sigs = glob.glob(testdata_glob) - - query_ss = sourmash.load_one_signature(testdata_combined, ksize=21) - subject_sigs = [ (sourmash.load_one_signature(t, ksize=21), t) - for t in testdata_sigs ] - # load up the counter - counter = CounterGather(query_ss.minhash) - for ss, loc in subject_sigs: - counter.add(ss, loc) - - results = _consume_all(query_ss.minhash, counter) - - expected = (['NC_003198.1', 487], - ['NC_000853.1', 192], - ['NC_011978.1', 169], - ['NC_002163.1', 157], - ['NC_003197.2', 152], - ['NC_009486.1', 92], - ['NC_006905.1', 76], - ['NC_011080.1', 59], - ['NC_011274.1', 42], - ['NC_006511.1', 31], - ['NC_011294.1', 7], - ['NC_004631.1', 2]) - assert len(results) == len(expected) - - for (sr, size), (exp_name, exp_size) in zip(results, expected): - sr_name = sr.signature.name.split()[0] - print(sr_name, size) - - assert sr_name == exp_name - assert size == exp_size - - -def test_counter_gather_exact_match(): - # query == match - query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) - query_mh.add_many(range(0, 20)) - query_ss = SourmashSignature(query_mh, name='query') - - # load up the counter - counter = CounterGather(query_ss.minhash) - counter.add(query_ss, 'somewhere over the rainbow') - - results = _consume_all(query_ss.minhash, counter) - assert len(results) == 1 - (sr, intersect_mh) = results[0] - - assert sr.score == 1.0 - assert sr.signature == query_ss - assert sr.location == 'somewhere over the rainbow' - - -def test_counter_gather_add_after_peek(): - # cannot add after peek or consume - query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) - query_mh.add_many(range(0, 20)) - query_ss = SourmashSignature(query_mh, name='query') - - # load up the counter - counter = CounterGather(query_ss.minhash) - counter.add(query_ss, 'somewhere over the rainbow') - - counter.peek(query_ss.minhash) - - with pytest.raises(ValueError): - counter.add(query_ss, "try again") - - -def test_counter_gather_add_after_consume(): - # cannot add after peek or consume - query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) - query_mh.add_many(range(0, 20)) - query_ss = SourmashSignature(query_mh, name='query') - - # load up the counter - counter = CounterGather(query_ss.minhash) - counter.add(query_ss, 'somewhere over the rainbow') - - counter.consume(query_ss.minhash) - - with pytest.raises(ValueError): - counter.add(query_ss, "try again") - - -def test_counter_gather_consume_empty_intersect(): - # check that consume works fine when there is an empty signature. - query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) - query_mh.add_many(range(0, 20)) - query_ss = SourmashSignature(query_mh, name='query') - - # load up the counter - counter = CounterGather(query_ss.minhash) - counter.add(query_ss, 'somewhere over the rainbow') - - # nothing really happens here :laugh:, just making sure there's no error - counter.consume(query_ss.minhash.copy_and_clear()) - - -def test_counter_gather_empty_initial_query(): - # check empty initial query - query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) - query_ss = SourmashSignature(query_mh, name='query') - - match_mh_1 = query_mh.copy_and_clear() - match_mh_1.add_many(range(0, 10)) - match_ss_1 = SourmashSignature(match_mh_1, name='match1') - - # load up the counter - counter = CounterGather(query_ss.minhash) - counter.add(match_ss_1, require_overlap=False) - - assert counter.peek(query_ss.minhash) == [] - - -def test_counter_gather_num_query(): - # check num query - query_mh = sourmash.MinHash(n=500, ksize=31) - query_mh.add_many(range(0, 10)) - query_ss = SourmashSignature(query_mh, name='query') - - with pytest.raises(ValueError): - counter = CounterGather(query_ss.minhash) - - -def test_counter_gather_empty_cur_query(): - # test empty cur query - query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) - query_mh.add_many(range(0, 20)) - query_ss = SourmashSignature(query_mh, name='query') - - # load up the counter - counter = CounterGather(query_ss.minhash) - counter.add(query_ss, 'somewhere over the rainbow') - - cur_query_mh = query_ss.minhash.copy_and_clear() - results = _consume_all(cur_query_mh, counter) - assert results == [] - - -def test_counter_gather_add_num_matchy(): - # test add num query - query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) - query_mh.add_many(range(0, 20)) - query_ss = SourmashSignature(query_mh, name='query') - - match_mh = sourmash.MinHash(n=500, ksize=31) - match_mh.add_many(range(0, 20)) - match_ss = SourmashSignature(match_mh, name='query') - - # load up the counter - counter = CounterGather(query_ss.minhash) - with pytest.raises(ValueError): - counter.add(match_ss, 'somewhere over the rainbow') - - -def test_counter_gather_bad_cur_query(): - # test cur query that is not subset of original query - query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) - query_mh.add_many(range(0, 20)) - query_ss = SourmashSignature(query_mh, name='query') - - # load up the counter - counter = CounterGather(query_ss.minhash) - counter.add(query_ss, 'somewhere over the rainbow') - - cur_query_mh = query_ss.minhash.copy_and_clear() - cur_query_mh.add_many(range(20, 30)) - with pytest.raises(ValueError): - counter.peek(cur_query_mh) - - -def test_counter_gather_add_no_overlap(): - # check adding match with no overlap w/query - query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) - query_mh.add_many(range(0, 10)) - query_ss = SourmashSignature(query_mh, name='query') - - match_mh_1 = query_mh.copy_and_clear() - match_mh_1.add_many(range(10, 20)) - match_ss_1 = SourmashSignature(match_mh_1, name='match1') - - # load up the counter - counter = CounterGather(query_ss.minhash) - with pytest.raises(ValueError): - counter.add(match_ss_1) - - assert counter.peek(query_ss.minhash) == [] - - -def test_counter_gather_big_threshold(): - # check 'peek' with a huge threshold - query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) - query_mh.add_many(range(0, 20)) - query_ss = SourmashSignature(query_mh, name='query') - - match_mh_1 = query_mh.copy_and_clear() - match_mh_1.add_many(range(0, 10)) - match_ss_1 = SourmashSignature(match_mh_1, name='match1') - - # load up the counter - counter = CounterGather(query_ss.minhash) - counter.add(match_ss_1) - - # impossible threshold: - threshold_bp=30*query_ss.minhash.scaled - results = counter.peek(query_ss.minhash, threshold_bp=threshold_bp) - assert results == [] - - -def test_counter_gather_empty_counter(): - # check empty counter - query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) - query_ss = SourmashSignature(query_mh, name='query') - - # empty counter! - counter = CounterGather(query_ss.minhash) - - assert counter.peek(query_ss.minhash) == [] - - -def test_counter_gather_3_test_consume(): - # open-box testing of consume(...) +def test_counter_gather_test_consume(): + # open-box testing of CounterGather.consume(...) + # (see test_index_protocol.py for generic CounterGather tests.) query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) query_mh.add_many(range(0, 20)) query_ss = SourmashSignature(query_mh, name='query') @@ -2099,20 +1567,24 @@ def test_counter_gather_3_test_consume(): match_ss_3 = SourmashSignature(match_mh_3, name='match3') # load up the counter - counter = CounterGather(query_ss.minhash) - counter.add(match_ss_1, 'loc a') - counter.add(match_ss_2, 'loc b') - counter.add(match_ss_3, 'loc c') + counter = CounterGather(query_ss) + counter.add(match_ss_1, location='loc a') + counter.add(match_ss_2, location='loc b') + counter.add(match_ss_3, location='loc c') ### ok, dig into actual counts... import pprint pprint.pprint(counter.counter) - pprint.pprint(counter.siglist) + pprint.pprint(list(counter.signatures())) pprint.pprint(counter.locations) - assert counter.siglist == [ match_ss_1, match_ss_2, match_ss_3 ] - assert counter.locations == ['loc a', 'loc b', 'loc c'] - assert list(counter.counter.items()) == [(0, 10), (1, 8), (2, 4)] + assert set(counter.signatures()) == set([match_ss_1, match_ss_2, match_ss_3]) + assert list(sorted(counter.locations.values())) == ['loc a', 'loc b', 'loc c'] + pprint.pprint(counter.counter.most_common()) + assert list(counter.counter.most_common()) == \ + [('26d4943627b33c446f37be1f5baf8d46', 10), + ('f51cedec90ea666e0ebc11aa274eca61', 8), + ('f331f8279113d77e42ab8efca8f9cc17', 4)] ## round 1 @@ -2123,9 +1595,12 @@ def test_counter_gather_3_test_consume(): assert cur_query == query_ss.minhash counter.consume(intersect_mh) - assert counter.siglist == [ match_ss_1, match_ss_2, match_ss_3 ] - assert counter.locations == ['loc a', 'loc b', 'loc c'] - assert list(counter.counter.items()) == [(1, 5), (2, 4)] + assert set(counter.signatures()) == set([ match_ss_1, match_ss_2, match_ss_3 ]) + assert list(sorted(counter.locations.values())) == ['loc a', 'loc b', 'loc c'] + pprint.pprint(counter.counter.most_common()) + assert list(counter.counter.most_common()) == \ + [('f51cedec90ea666e0ebc11aa274eca61', 5), + ('f331f8279113d77e42ab8efca8f9cc17', 4)] ### round 2 @@ -2136,9 +1611,12 @@ def test_counter_gather_3_test_consume(): assert cur_query != query_ss.minhash counter.consume(intersect_mh) - assert counter.siglist == [ match_ss_1, match_ss_2, match_ss_3 ] - assert counter.locations == ['loc a', 'loc b', 'loc c'] - assert list(counter.counter.items()) == [(2, 2)] + assert set(counter.signatures()) == set([ match_ss_1, match_ss_2, match_ss_3 ]) + assert list(sorted(counter.locations.values())) == ['loc a', 'loc b', 'loc c'] + + pprint.pprint(counter.counter.most_common()) + assert list(counter.counter.most_common()) == \ + [('f331f8279113d77e42ab8efca8f9cc17', 2)] ## round 3 @@ -2149,9 +1627,10 @@ def test_counter_gather_3_test_consume(): assert cur_query != query_ss.minhash counter.consume(intersect_mh) - assert counter.siglist == [ match_ss_1, match_ss_2, match_ss_3 ] - assert counter.locations == ['loc a', 'loc b', 'loc c'] - assert list(counter.counter.items()) == [] + assert set(counter.signatures()) == set([ match_ss_1, match_ss_2, match_ss_3 ]) + assert list(sorted(counter.locations.values())) == ['loc a', 'loc b', 'loc c'] + pprint.pprint(counter.counter.most_common()) + assert list(counter.counter.most_common()) == [] ## round 4 - nothing left! @@ -2160,9 +1639,41 @@ def test_counter_gather_3_test_consume(): assert not results counter.consume(intersect_mh) - assert counter.siglist == [ match_ss_1, match_ss_2, match_ss_3 ] - assert counter.locations == ['loc a', 'loc b', 'loc c'] - assert list(counter.counter.items()) == [] + assert set(counter.signatures()) == set([ match_ss_1, match_ss_2, match_ss_3 ]) + assert list(sorted(counter.locations.values())) == ['loc a', 'loc b', 'loc c'] + assert list(counter.counter.most_common()) == [] + + +def test_counter_gather_identical_md5sum(): + # open-box testing of CounterGather.consume(...) + # check what happens with identical matches w/different names + query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) + query_mh.add_many(range(0, 20)) + query_ss = SourmashSignature(query_mh, name='query') + + match_mh_1 = query_mh.copy_and_clear() + match_mh_1.add_many(range(0, 10)) + match_ss_1 = SourmashSignature(match_mh_1, name='match1') + + # same as match_mh_1 + match_mh_2 = query_mh.copy_and_clear() + match_mh_2.add_many(range(0, 10)) + match_ss_2 = SourmashSignature(match_mh_2, name='match2') + + # identical md5sum + assert match_ss_1.md5sum() == match_ss_2.md5sum() + + # load up the counter + counter = CounterGather(query_ss) + counter.add(match_ss_1, location='loc a') + counter.add(match_ss_2, location='loc b') + + assert len(counter.siglist) == 1 + stored_match = list(counter.siglist.values()).pop() + assert stored_match.name == 'match2' + # CTB note: this behavior may be changed freely, as the protocol + # tests simply specify that _one_ of the identical matches is + # returned. See test_counter_gather_multiple_identical_matches. def test_lazy_index_1(): @@ -2264,103 +1775,6 @@ def test_lazy_index_wraps_multi_index_location(): lazy2.signatures_with_location()): assert ss_tup == ss_lazy_tup -def test_lazy_loaded_index_1(runtmp): - # some basic tests for LazyLoadedIndex - lcafile = utils.get_test_data('prot/protein.lca.json.gz') - sigzip = utils.get_test_data('prot/protein.zip') - - with pytest.raises(ValueError) as exc: - db = index.LazyLoadedIndex.load(lcafile) - # no manifest on LCA database - assert "no manifest on index at" in str(exc) - - # load something, check that it's only accessed upon .signatures(...) - test_zip = runtmp.output('test.zip') - shutil.copyfile(sigzip, test_zip) - db = index.LazyLoadedIndex.load(test_zip) - assert len(db) == 2 - assert db.location == test_zip - - # now remove! - os.unlink(test_zip) - - # can still access manifest... - assert len(db) == 2 - - # ...but we should get an error when we call signatures. - with pytest.raises(ValueError): - list(db.signatures()) - - # but put it back, and all is forgiven. yay! - shutil.copyfile(sigzip, test_zip) - x = list(db.signatures()) - assert len(x) == 2 - - -def test_lazy_loaded_index_2_empty(runtmp): - # some basic tests for LazyLoadedIndex that is empty - sigzip = utils.get_test_data('prot/protein.zip') - - # load something: - test_zip = runtmp.output('test.zip') - shutil.copyfile(sigzip, test_zip) - db = index.LazyLoadedIndex.load(test_zip) - assert len(db) == 2 - assert db.location == test_zip - assert bool(db) - - # select to empty: - db = db.select(ksize=50) - - assert len(db) == 0 - assert not bool(db) - - x = list(db.signatures()) - assert len(x) == 0 - - -def test_lazy_loaded_index_3_find(runtmp): - # test 'find' - query_file = utils.get_test_data('prot/protein/GCA_001593925.1_ASM159392v1_protein.faa.gz.sig') - sigzip = utils.get_test_data('prot/protein.zip') - - # load something: - test_zip = runtmp.output('test.zip') - shutil.copyfile(sigzip, test_zip) - db = index.LazyLoadedIndex.load(test_zip) - - # can we find matches? should find two. - query = sourmash.load_one_signature(query_file) - assert query.minhash.ksize == 19 - x = db.search(query, threshold=0.0) - x = list(x) - assert len(x) == 2 - - # no matches! - db = db.select(ksize=20) - x = db.search(query, threshold=0.0) - x = list(x) - assert len(x) == 0 - - -def test_lazy_loaded_index_4_nofile(runtmp): - # test check for filename must exist - with pytest.raises(ValueError) as exc: - index.LazyLoadedIndex(runtmp.output('xyz'), True) - - assert "must exist when creating" in str(exc) - - -def test_lazy_loaded_index_4_nomanifest(runtmp): - # test check for empty manifest - sig2 = utils.get_test_data("2.fa.sig") - - with pytest.raises(ValueError) as exc: - index.LazyLoadedIndex(sig2, None) - - assert "manifest cannot be None" in str(exc) - - def test_revindex_index_search(): # confirm that RevIndex works sig2 = utils.get_test_data("2.fa.sig") @@ -2407,7 +1821,7 @@ def test_revindex_index_search(): def test_revindex_gather(): - # check that RevIndex.gather works. + # check that RevIndex.best_containment works. sig2 = utils.get_test_data("2.fa.sig") sig47 = utils.get_test_data("47.fa.sig") sig63 = utils.get_test_data("63.fa.sig") @@ -2421,15 +1835,15 @@ def test_revindex_gather(): lidx.insert(ss47) lidx.insert(ss63) - matches = lidx.gather(ss2) - assert len(matches) == 1 - assert matches[0][0] == 1.0 - assert matches[0][1] == ss2 + match = lidx.best_containment(ss2) + assert match + assert match.score == 1.0 + assert match.signature == ss2 - matches = lidx.gather(ss47) - assert len(matches) == 1 - assert matches[0][0] == 1.0 - assert matches[0][1] == ss47 + match = lidx.best_containment(ss47) + assert match + assert match.score == 1.0 + assert match.signature == ss47 def test_revindex_gather_ignore(): @@ -2533,13 +1947,15 @@ def test_standalone_manifest_signatures_prefix_fail(runtmp): row['internal_location'] = os.path.basename(row['internal_location']) ## got a manifest! ok, now test out StandaloneManifestIndex - mm = StandaloneManifestIndex(mi.manifest, None, prefix='foo') + mm = StandaloneManifestIndex(mi.manifest, None, + prefix=runtmp.output('foo')) # should fail with pytest.raises(ValueError) as exc: list(mm.signatures()) - assert "Error while reading signatures from 'foo/47.fa.sig'" in str(exc) + assert "Error while reading signatures from " in str(exc) + assert "foo/47.fa.sig'" in str(exc) def test_standalone_manifest_load_from_dir(runtmp): diff --git a/tests/test_index_protocol.py b/tests/test_index_protocol.py index 22498fcd04..4a6672408e 100644 --- a/tests/test_index_protocol.py +++ b/tests/test_index_protocol.py @@ -4,17 +4,22 @@ """ import pytest +import glob import sourmash from sourmash import SourmashSignature from sourmash.index import (LinearIndex, ZipFileLinearIndex, LazyLinearIndex, MultiIndex, - StandaloneManifestIndex, LazyLoadedIndex) + StandaloneManifestIndex, + IndexSearchResult) +from sourmash.index import CounterGather from sourmash.index.sqlite_index import SqliteIndex from sourmash.index.revindex import RevIndex from sourmash.sbt import SBT, GraphFactory from sourmash.manifest import CollectionManifest, BaseCollectionManifest from sourmash.lca.lca_db import LCA_Database, load_single_database +from sourmash.minhash import (flatten_and_intersect_scaled, + flatten_and_downsample_scaled) import sourmash_tst_utils as utils @@ -70,7 +75,7 @@ def build_sbt_index_save_load(runtmp): def build_zipfile_index(runtmp): - from sourmash.sourmash_args import SaveSignatures_ZipFile + from sourmash.save_load import SaveSignatures_ZipFile location = runtmp.output('index.zip') with SaveSignatures_ZipFile(location) as save_sigs: @@ -128,14 +133,6 @@ def build_lca_index_save_load(runtmp): return sourmash.load_file_as_index(outfile) -def build_lca_index_save_load(runtmp): - db = build_lca_index(runtmp) - outfile = runtmp.output('db.lca.json') - db.save(outfile) - - return sourmash.load_file_as_index(outfile) - - def build_sqlite_index(runtmp): filename = runtmp.output('idx.sqldb') db = SqliteIndex.create(filename) @@ -147,15 +144,6 @@ def build_sqlite_index(runtmp): return db -def build_lazy_loaded_index(runtmp): - db = build_lca_index(runtmp) - outfile = runtmp.output('db.lca.json') - db.save(outfile) - - mf = CollectionManifest.create_manifest(db._signatures_with_internal()) - return LazyLoadedIndex(outfile, mf) - - def build_revindex(runtmp): ss2, ss47, ss63 = _load_three_sigs() @@ -194,7 +182,6 @@ def build_lca_index_save_load_sql(runtmp): build_lca_index_save_load, build_sqlite_index, build_lca_index_save_load_sql, - build_lazy_loaded_index, # build_revindex, ] ) @@ -381,23 +368,23 @@ def test_index_prefetch(index_obj): assert results[1].signature.minhash == ss63.minhash -def test_index_gather(index_obj): - # test basic gather +def test_index_best_containment(index_obj): + # test basic containment search ss2, ss47, ss63 = _load_three_sigs() - matches = index_obj.gather(ss2) - assert len(matches) == 1 - assert matches[0].score == 1.0 - assert matches[0].signature.minhash == ss2.minhash + match = index_obj.best_containment(ss2) + assert match + assert match.score == 1.0 + assert match.signature.minhash == ss2.minhash - matches = index_obj.gather(ss47) - assert len(matches) == 1 - assert matches[0].score == 1.0 - assert matches[0].signature.minhash == ss47.minhash + match = index_obj.best_containment(ss47) + assert match + assert match.score == 1.0 + assert match.signature.minhash == ss47.minhash -def test_index_gather_threshold_1(index_obj): - # test gather() method, in some detail +def test_index_best_containment_threshold_1(index_obj): + # test best_containment() method, in some detail ss2, ss47, ss63 = _load_three_sigs() # now construct query signatures with specific numbers of hashes -- @@ -409,21 +396,21 @@ def test_index_gather_threshold_1(index_obj): # query with empty hashes assert not new_mh with pytest.raises(ValueError): - index_obj.gather(SourmashSignature(new_mh)) + index_obj.best_containment(SourmashSignature(new_mh)) # add one hash new_mh.add_hash(mins.pop()) assert len(new_mh) == 1 - results = index_obj.gather(SourmashSignature(new_mh)) - assert len(results) == 1 - containment, match_sig, name = results[0] + result = index_obj.best_containment(SourmashSignature(new_mh)) + assert result + containment, match_sig, name = result assert containment == 1.0 assert match_sig.minhash == ss2.minhash # check with a threshold -> should be no results. with pytest.raises(ValueError): - index_obj.gather(SourmashSignature(new_mh), threshold_bp=5000) + index_obj.best_containment(SourmashSignature(new_mh), threshold_bp=5000) # add three more hashes => length of 4 new_mh.add_hash(mins.pop()) @@ -431,18 +418,18 @@ def test_index_gather_threshold_1(index_obj): new_mh.add_hash(mins.pop()) assert len(new_mh) == 4 - results = index_obj.gather(SourmashSignature(new_mh)) - assert len(results) == 1 - containment, match_sig, name = results[0] + result = index_obj.best_containment(SourmashSignature(new_mh)) + assert result + containment, match_sig, name = result assert containment == 1.0 assert match_sig.minhash == ss2.minhash # check with a too-high threshold -> should be no results. with pytest.raises(ValueError): - index_obj.gather(SourmashSignature(new_mh), threshold_bp=5000) + index_obj.best_containment(SourmashSignature(new_mh), threshold_bp=5000) -def test_gather_threshold_5(index_obj): +def test_best_containment_threshold_5(index_obj): # test gather() method, in some detail ss2, ss47, ss63 = _load_three_sigs() @@ -461,15 +448,804 @@ def test_gather_threshold_5(index_obj): new_mh.add_hash(mins.pop()) # should get a result with no threshold (any match at all is returned) - results = index_obj.gather(SourmashSignature(new_mh)) - assert len(results) == 1 - containment, match_sig, name = results[0] + result = index_obj.best_containment(SourmashSignature(new_mh)) + assert result + containment, match_sig, name = result assert containment == 1.0 assert match_sig.minhash == ss2.minhash # now, check with a threshold_bp that should be meet-able. - results = index_obj.gather(SourmashSignature(new_mh), threshold_bp=5000) - assert len(results) == 1 - containment, match_sig, name = results[0] + result = index_obj.best_containment(SourmashSignature(new_mh), threshold_bp=5000) + assert result + containment, match_sig, name = result assert containment == 1.0 assert match_sig.minhash == ss2.minhash + + +### +### CounterGather tests +### + + +def create_basic_counter_gather(runtmp): + "Construct a CounterGather class." + return CounterGather + + +class CounterGather_LinearIndex: + """ + Provides an (inefficient) CounterGather-style class, for + protocol testing purposes. + """ + def __init__(self, orig_query): + "Constructor - take a SourmashSignature that is the original query." + orig_query_mh = orig_query.minhash + if orig_query_mh.scaled == 0: + raise ValueError + + # Index object used to actually track matches. + self.idx = LinearIndex() + self.orig_query_mh = orig_query_mh.copy().flatten() + self.query_started = 0 + self.scaled = orig_query_mh.scaled + self.locations = {} + + def add(self, ss, *, location=None, require_overlap=True): + "Insert potential match." + if self.query_started: + raise ValueError("cannot add more signatures to counter after peek/consume") + + # skip duplicates + md5 = ss.md5sum() + if md5 in self.locations: + return + + # confirm that this match has an overlap... + add_mh = ss.minhash.flatten() + overlap = self.orig_query_mh.count_common(add_mh, downsample=True) + + # ...figure out what scaled we are operating at now... + if overlap: + self.downsample(add_mh.scaled) + elif require_overlap: + raise ValueError("no overlap between query and signature!?") + + # ...and add to the Index, while also tracking location! + self.idx.insert(ss) + self.locations[md5] = location + + def signatures(self): + "Yield all signatures" + return self.idx.signatures() + + def downsample(self, scaled): + "Track highest scaled across all possible matches." + if scaled > self.scaled: + self.scaled = scaled + return self.scaled + + def peek(self, cur_query_mh, *, threshold_bp=0): + """ + Find best match to current query within this CounterGather object. + """ + self.query_started = 1 + + scaled = self.downsample(cur_query_mh.scaled) + cur_query_mh = flatten_and_downsample_scaled(cur_query_mh, scaled) + + # no hashes remaining? exit. + if not self.orig_query_mh or not cur_query_mh: + return [] + + # verify current query is a subset of the original. + if cur_query_mh.contained_by(self.orig_query_mh, downsample=True) < 1: + raise ValueError("current query not a subset of original query") + + # did we get a match? + res = self.idx.peek(cur_query_mh, threshold_bp=threshold_bp) + if not res: + return [] + sr, intersect_mh = res + + # got match - replace location & return. + match = sr.signature + md5 = match.md5sum() + location = self.locations[md5] + return IndexSearchResult(sr.score, match, location), intersect_mh + + def consume(self, *args, **kwargs): + self.query_started = 1 + return self.idx.consume(*args, **kwargs) + + +class CounterGather_LCA: + """ + Provides an alternative implementation of a CounterGather-style class, + based on LCA_Database. This is currently just for protocol + and API testing purposes. + """ + def __init__(self, query): + from sourmash.lca.lca_db import LCA_Database + + query_mh = query.minhash + if query_mh.scaled == 0: + raise ValueError("must use scaled MinHash") + + self.orig_query_mh = query_mh + lca_db = LCA_Database(query_mh.ksize, query_mh.scaled, + query_mh.moltype) + self.db = lca_db + self.siglist = {} + self.locations = {} + self.query_started = 0 + + def add(self, ss, *, location=None, require_overlap=True): + "Add this signature into the counter." + if self.query_started: + raise ValueError("cannot add more signatures to counter after peek/consume") + + overlap = self.orig_query_mh.count_common(ss.minhash, True) + if overlap: + self.downsample(ss.minhash.scaled) + elif require_overlap: + raise ValueError("no overlap between query and signature!?") + + self.db.insert(ss) + + md5 = ss.md5sum() + self.siglist[md5] = ss + self.locations[md5] = location + + def signatures(self): + "Yield all signatures." + for ss in self.siglist.values(): + yield ss + + def downsample(self, scaled): + "Track highest scaled across all possible matches." + if scaled > self.db.scaled: + self.db.downsample_scaled(scaled) + return self.db.scaled + + def peek(self, query_mh, *, threshold_bp=0): + "Return next possible match." + from sourmash import SourmashSignature + + self.query_started = 1 + scaled = self.downsample(query_mh.scaled) + query_mh = query_mh.downsample(scaled=scaled) + + if not self.orig_query_mh or not query_mh: + return [] + + if query_mh.contained_by(self.orig_query_mh, downsample=True) < 1: + raise ValueError("current query not a subset of original query") + + query_ss = SourmashSignature(query_mh) + + # returns search_result, intersect_mh + try: + result = self.db.best_containment(query_ss, threshold_bp=threshold_bp) + except ValueError: + result = None + + if not result: + return [] + + cont = result.score + match = result.signature + + intersect_mh = flatten_and_intersect_scaled(result.signature.minhash, + query_mh) + + md5 = result.signature.md5sum() + location = self.locations[md5] + + new_sr = IndexSearchResult(cont, match, location) + return [new_sr, intersect_mh] + + def consume(self, intersect_mh): + self.query_started = 1 + + +@pytest.fixture(params=[CounterGather, + CounterGather_LinearIndex, + CounterGather_LCA, + ] +) +def counter_gather_constructor(request): + build_fn = request.param + + # build on demand + return build_fn + + +def test_counter_get_signatures(counter_gather_constructor): + # test .signatures() method + query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) + query_mh.add_many(range(0, 20)) + query_ss = SourmashSignature(query_mh, name='query') + + match_mh_1 = query_mh.copy_and_clear() + match_mh_1.add_many(range(0, 10)) + match_ss_1 = SourmashSignature(match_mh_1, name='match1') + + match_mh_2 = query_mh.copy_and_clear() + match_mh_2.add_many(range(10, 15)) + match_ss_2 = SourmashSignature(match_mh_2, name='match2') + + match_mh_3 = query_mh.copy_and_clear() + match_mh_3.add_many(range(15, 17)) + match_ss_3 = SourmashSignature(match_mh_3, name='match3') + + counter = counter_gather_constructor(query_ss) + counter.add(match_ss_1) + counter.add(match_ss_2) + counter.add(match_ss_3) + + siglist = list(counter.signatures()) + assert len(siglist) == 3 + assert match_ss_1 in siglist + assert match_ss_2 in siglist + assert match_ss_3 in siglist + + +def _consume_all(query_mh, counter, threshold_bp=0): + results = [] + query_mh = query_mh.to_mutable() + + last_intersect_size = None + while 1: + result = counter.peek(query_mh, threshold_bp=threshold_bp) + if not result: + break + + sr, intersect_mh = result + print(sr.signature.name, len(intersect_mh)) + if last_intersect_size: + assert len(intersect_mh) <= last_intersect_size + + last_intersect_size = len(intersect_mh) + + counter.consume(intersect_mh) + query_mh.remove_many(intersect_mh.hashes) + + results.append((sr, len(intersect_mh))) + + return results + + +def test_counter_gather_1(counter_gather_constructor): + # check a contrived set of non-overlapping gather results, + # generated via CounterGather + query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) + query_mh.add_many(range(0, 20)) + query_ss = SourmashSignature(query_mh, name='query') + + match_mh_1 = query_mh.copy_and_clear() + match_mh_1.add_many(range(0, 10)) + match_ss_1 = SourmashSignature(match_mh_1, name='match1') + + match_mh_2 = query_mh.copy_and_clear() + match_mh_2.add_many(range(10, 15)) + match_ss_2 = SourmashSignature(match_mh_2, name='match2') + + match_mh_3 = query_mh.copy_and_clear() + match_mh_3.add_many(range(15, 17)) + match_ss_3 = SourmashSignature(match_mh_3, name='match3') + + # load up the counter + counter = counter_gather_constructor(query_ss) + counter.add(match_ss_1) + counter.add(match_ss_2) + counter.add(match_ss_3) + + results = _consume_all(query_ss.minhash, counter) + + expected = (['match1', 10], + ['match2', 5], + ['match3', 2],) + assert len(results) == len(expected), results + + for (sr, size), (exp_name, exp_size) in zip(results, expected): + sr_name = sr.signature.name.split()[0] + + assert sr_name == exp_name + assert size == exp_size + + +def test_counter_gather_1_b(counter_gather_constructor): + # check a contrived set of somewhat-overlapping gather results, + # generated via CounterGather. Here the overlaps are structured + # so that the gather results are the same as those in + # test_counter_gather_1(), even though the overlaps themselves are + # larger. + query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) + query_mh.add_many(range(0, 20)) + query_ss = SourmashSignature(query_mh, name='query') + + match_mh_1 = query_mh.copy_and_clear() + match_mh_1.add_many(range(0, 10)) + match_ss_1 = SourmashSignature(match_mh_1, name='match1') + + match_mh_2 = query_mh.copy_and_clear() + match_mh_2.add_many(range(7, 15)) + match_ss_2 = SourmashSignature(match_mh_2, name='match2') + + match_mh_3 = query_mh.copy_and_clear() + match_mh_3.add_many(range(13, 17)) + match_ss_3 = SourmashSignature(match_mh_3, name='match3') + + # load up the counter + counter = counter_gather_constructor(query_ss) + counter.add(match_ss_1) + counter.add(match_ss_2) + counter.add(match_ss_3) + + results = _consume_all(query_ss.minhash, counter) + + expected = (['match1', 10], + ['match2', 5], + ['match3', 2],) + assert len(results) == len(expected), results + + for (sr, size), (exp_name, exp_size) in zip(results, expected): + sr_name = sr.signature.name.split()[0] + + assert sr_name == exp_name + assert size == exp_size + + +def test_counter_gather_1_c_with_threshold(counter_gather_constructor): + # check a contrived set of somewhat-overlapping gather results, + # generated via CounterGather. Here the overlaps are structured + # so that the gather results are the same as those in + # test_counter_gather_1(), even though the overlaps themselves are + # larger. + # use a threshold, here. + + query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) + query_mh.add_many(range(0, 20)) + query_ss = SourmashSignature(query_mh, name='query') + + match_mh_1 = query_mh.copy_and_clear() + match_mh_1.add_many(range(0, 10)) + match_ss_1 = SourmashSignature(match_mh_1, name='match1') + + match_mh_2 = query_mh.copy_and_clear() + match_mh_2.add_many(range(7, 15)) + match_ss_2 = SourmashSignature(match_mh_2, name='match2') + + match_mh_3 = query_mh.copy_and_clear() + match_mh_3.add_many(range(13, 17)) + match_ss_3 = SourmashSignature(match_mh_3, name='match3') + + # load up the counter + counter = counter_gather_constructor(query_ss) + counter.add(match_ss_1) + counter.add(match_ss_2) + counter.add(match_ss_3) + + results = _consume_all(query_ss.minhash, counter, + threshold_bp=3) + + expected = (['match1', 10], + ['match2', 5]) + assert len(results) == len(expected), results + + for (sr, size), (exp_name, exp_size) in zip(results, expected): + sr_name = sr.signature.name.split()[0] + + assert sr_name == exp_name + assert size == exp_size + + +def test_counter_gather_1_d_diff_scaled(counter_gather_constructor): + # test as above, but with different scaled. + query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) + query_mh.add_many(range(0, 20)) + query_ss = SourmashSignature(query_mh, name='query') + + match_mh_1 = query_mh.copy_and_clear().downsample(scaled=10) + match_mh_1.add_many(range(0, 10)) + match_ss_1 = SourmashSignature(match_mh_1, name='match1') + + match_mh_2 = query_mh.copy_and_clear().downsample(scaled=20) + match_mh_2.add_many(range(7, 15)) + match_ss_2 = SourmashSignature(match_mh_2, name='match2') + + match_mh_3 = query_mh.copy_and_clear().downsample(scaled=30) + match_mh_3.add_many(range(13, 17)) + match_ss_3 = SourmashSignature(match_mh_3, name='match3') + + # load up the counter + counter = counter_gather_constructor(query_ss) + counter.add(match_ss_1) + counter.add(match_ss_2) + counter.add(match_ss_3) + + results = _consume_all(query_ss.minhash, counter) + + expected = (['match1', 10], + ['match2', 5], + ['match3', 2],) + assert len(results) == len(expected), results + + for (sr, size), (exp_name, exp_size) in zip(results, expected): + sr_name = sr.signature.name.split()[0] + + assert sr_name == exp_name + assert size == exp_size + + +def test_counter_gather_1_d_diff_scaled_query(counter_gather_constructor): + # test as above, but with different scaled for QUERY. + query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) + query_mh.add_many(range(0, 20)) + + match_mh_1 = query_mh.copy_and_clear().downsample(scaled=10) + match_mh_1.add_many(range(0, 10)) + match_ss_1 = SourmashSignature(match_mh_1, name='match1') + + match_mh_2 = query_mh.copy_and_clear().downsample(scaled=20) + match_mh_2.add_many(range(7, 15)) + match_ss_2 = SourmashSignature(match_mh_2, name='match2') + + match_mh_3 = query_mh.copy_and_clear().downsample(scaled=30) + match_mh_3.add_many(range(13, 17)) + match_ss_3 = SourmashSignature(match_mh_3, name='match3') + + # downsample query now - + query_ss = SourmashSignature(query_mh.downsample(scaled=100), name='query') + + # load up the counter + counter = counter_gather_constructor(query_ss) + counter.add(match_ss_1) + counter.add(match_ss_2) + counter.add(match_ss_3) + + results = _consume_all(query_ss.minhash, counter) + + expected = (['match1', 10], + ['match2', 5], + ['match3', 2],) + assert len(results) == len(expected), results + + for (sr, size), (exp_name, exp_size) in zip(results, expected): + sr_name = sr.signature.name.split()[0] + + assert sr_name == exp_name + assert size == exp_size + + +def test_counter_gather_1_e_abund_query(counter_gather_constructor): + # test as above, but abund query + query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1, track_abundance=1) + query_mh.add_many(range(0, 20)) + query_ss = SourmashSignature(query_mh, name='query') + + match_mh_1 = query_mh.copy_and_clear().flatten() + match_mh_1.add_many(range(0, 10)) + match_ss_1 = SourmashSignature(match_mh_1, name='match1') + + match_mh_2 = query_mh.copy_and_clear().flatten() + match_mh_2.add_many(range(7, 15)) + match_ss_2 = SourmashSignature(match_mh_2, name='match2') + + match_mh_3 = query_mh.copy_and_clear().flatten() + match_mh_3.add_many(range(13, 17)) + match_ss_3 = SourmashSignature(match_mh_3, name='match3') + + # load up the counter + counter = counter_gather_constructor(query_ss) + counter.add(match_ss_1) + counter.add(match_ss_2) + counter.add(match_ss_3) + + # must flatten before peek! + results = _consume_all(query_ss.minhash.flatten(), counter) + + expected = (['match1', 10], + ['match2', 5], + ['match3', 2],) + assert len(results) == len(expected), results + + for (sr, size), (exp_name, exp_size) in zip(results, expected): + sr_name = sr.signature.name.split()[0] + + assert sr_name == exp_name + assert size == exp_size + + +def test_counter_gather_1_f_abund_match(counter_gather_constructor): + # test as above, but abund query + query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1, track_abundance=1) + query_mh.add_many(range(0, 20)) + query_ss = SourmashSignature(query_mh.flatten(), name='query') + + match_mh_1 = query_mh.copy_and_clear() + match_mh_1.add_many(range(0, 10)) + match_ss_1 = SourmashSignature(match_mh_1, name='match1') + + match_mh_2 = query_mh.copy_and_clear() + match_mh_2.add_many(range(7, 15)) + match_ss_2 = SourmashSignature(match_mh_2, name='match2') + + match_mh_3 = query_mh.copy_and_clear() + match_mh_3.add_many(range(13, 17)) + match_ss_3 = SourmashSignature(match_mh_3, name='match3') + + # load up the counter + counter = counter_gather_constructor(query_ss) + counter.add(match_ss_1) + counter.add(match_ss_2) + counter.add(match_ss_3) + + # must flatten before peek! + results = _consume_all(query_ss.minhash.flatten(), counter) + + expected = (['match1', 10], + ['match2', 5], + ['match3', 2],) + assert len(results) == len(expected), results + + for (sr, size), (exp_name, exp_size) in zip(results, expected): + sr_name = sr.signature.name.split()[0] + + assert sr_name == exp_name + assert size == exp_size + + +def test_counter_gather_2(counter_gather_constructor): + # check basic set of gather results on semi-real data, + # generated via CounterGather + testdata_combined = utils.get_test_data('gather/combined.sig') + testdata_glob = utils.get_test_data('gather/GCF*.sig') + testdata_sigs = glob.glob(testdata_glob) + + query_ss = sourmash.load_one_signature(testdata_combined, ksize=21) + subject_sigs = [ (sourmash.load_one_signature(t, ksize=21), t) + for t in testdata_sigs ] + + # load up the counter + counter = counter_gather_constructor(query_ss) + for ss, loc in subject_sigs: + counter.add(ss, location=loc) + + results = _consume_all(query_ss.minhash, counter) + + expected = (['NC_003198.1', 487], + ['NC_000853.1', 192], + ['NC_011978.1', 169], + ['NC_002163.1', 157], + ['NC_003197.2', 152], + ['NC_009486.1', 92], + ['NC_006905.1', 76], + ['NC_011080.1', 59], + ['NC_011274.1', 42], + ['NC_006511.1', 31], + ['NC_011294.1', 7], + ['NC_004631.1', 2]) + assert len(results) == len(expected) + + for (sr, size), (exp_name, exp_size) in zip(results, expected): + sr_name = sr.signature.name.split()[0] + print(sr_name, size) + + assert sr_name == exp_name + assert size == exp_size + + +def test_counter_gather_exact_match(counter_gather_constructor): + # query == match + query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) + query_mh.add_many(range(0, 20)) + query_ss = SourmashSignature(query_mh, name='query') + + # load up the counter; provide a location override, too. + counter = counter_gather_constructor(query_ss) + counter.add(query_ss, location='somewhere over the rainbow') + + results = _consume_all(query_ss.minhash, counter) + assert len(results) == 1 + (sr, intersect_mh) = results[0] + + assert sr.score == 1.0 + assert sr.signature == query_ss + assert sr.location == 'somewhere over the rainbow' + + +def test_counter_gather_multiple_identical_matches(counter_gather_constructor): + # test multiple identical matches being inserted, with only one return + query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) + query_mh.add_many(range(0, 20)) + query_ss = SourmashSignature(query_mh, name='query') + + # create counter... + counter = counter_gather_constructor(query_ss) + + # now add multiple identical matches. + match_mh = query_mh.copy_and_clear() + match_mh.add_many(range(5, 15)) + + for name in 'match1', 'match2', 'match3': + match_ss = SourmashSignature(match_mh, name=name) + counter.add(match_ss, location=name) + + results = _consume_all(query_ss.minhash, counter) + assert len(results) == 1 + + sr, overlap_count = results[0] + assert sr.score == 0.5 + assert overlap_count == 10 + + # any one of the three is valid + assert sr.location in ('match1', 'match2', 'match3') + + +def test_counter_gather_add_after_peek(counter_gather_constructor): + # cannot add after peek or consume + query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) + query_mh.add_many(range(0, 20)) + query_ss = SourmashSignature(query_mh, name='query') + + # load up the counter + counter = counter_gather_constructor(query_ss) + counter.add(query_ss, location='somewhere over the rainbow') + + counter.peek(query_ss.minhash) + + with pytest.raises(ValueError): + counter.add(query_ss, location="try again") + + +def test_counter_gather_add_after_consume(counter_gather_constructor): + # cannot add after peek or consume + query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) + query_mh.add_many(range(0, 20)) + query_ss = SourmashSignature(query_mh, name='query') + + # load up the counter + counter = counter_gather_constructor(query_ss) + counter.add(query_ss, location='somewhere over the rainbow') + + counter.consume(query_ss.minhash) + + with pytest.raises(ValueError): + counter.add(query_ss, location="try again") + + +def test_counter_gather_consume_empty_intersect(counter_gather_constructor): + # check that consume works fine when there is an empty signature. + query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) + query_mh.add_many(range(0, 20)) + query_ss = SourmashSignature(query_mh, name='query') + + # load up the counter + counter = counter_gather_constructor(query_ss) + counter.add(query_ss, location='somewhere over the rainbow') + + # nothing really happens here :laugh:, just making sure there's no error + counter.consume(query_ss.minhash.copy_and_clear()) + + +def test_counter_gather_empty_initial_query(counter_gather_constructor): + # check empty initial query + query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) + query_ss = SourmashSignature(query_mh, name='query') + + match_mh_1 = query_mh.copy_and_clear() + match_mh_1.add_many(range(0, 10)) + match_ss_1 = SourmashSignature(match_mh_1, name='match1') + + # load up the counter + counter = counter_gather_constructor(query_ss) + counter.add(match_ss_1, require_overlap=False) + + assert counter.peek(query_ss.minhash) == [] + + +def test_counter_gather_num_query(counter_gather_constructor): + # check num query + query_mh = sourmash.MinHash(n=500, ksize=31) + query_mh.add_many(range(0, 10)) + query_ss = SourmashSignature(query_mh, name='query') + + with pytest.raises(ValueError): + counter_gather_constructor(query_ss) + + +def test_counter_gather_empty_cur_query(counter_gather_constructor): + # test empty cur query + query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) + query_mh.add_many(range(0, 20)) + query_ss = SourmashSignature(query_mh, name='query') + + # load up the counter + counter = counter_gather_constructor(query_ss) + counter.add(query_ss, location='somewhere over the rainbow') + + cur_query_mh = query_ss.minhash.copy_and_clear() + results = _consume_all(cur_query_mh, counter) + assert results == [] + + +def test_counter_gather_add_num_matchy(counter_gather_constructor): + # test add num query + query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) + query_mh.add_many(range(0, 20)) + query_ss = SourmashSignature(query_mh, name='query') + + match_mh = sourmash.MinHash(n=500, ksize=31) + match_mh.add_many(range(0, 20)) + match_ss = SourmashSignature(match_mh, name='query') + + # load up the counter + counter = counter_gather_constructor(query_ss) + with pytest.raises(ValueError): + counter.add(match_ss, location='somewhere over the rainbow') + + +def test_counter_gather_bad_cur_query(counter_gather_constructor): + # test cur query that is not subset of original query + query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) + query_mh.add_many(range(0, 20)) + query_ss = SourmashSignature(query_mh, name='query') + + # load up the counter + counter = counter_gather_constructor(query_ss) + counter.add(query_ss, location='somewhere over the rainbow') + + cur_query_mh = query_ss.minhash.copy_and_clear() + cur_query_mh.add_many(range(20, 30)) + with pytest.raises(ValueError): + counter.peek(cur_query_mh) + + +def test_counter_gather_add_no_overlap(counter_gather_constructor): + # check adding match with no overlap w/query + query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) + query_mh.add_many(range(0, 10)) + query_ss = SourmashSignature(query_mh, name='query') + + match_mh_1 = query_mh.copy_and_clear() + match_mh_1.add_many(range(10, 20)) + match_ss_1 = SourmashSignature(match_mh_1, name='match1') + + # load up the counter + counter = counter_gather_constructor(query_ss) + with pytest.raises(ValueError): + counter.add(match_ss_1) + + assert counter.peek(query_ss.minhash) == [] + + +def test_counter_gather_big_threshold(counter_gather_constructor): + # check 'peek' with a huge threshold + query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) + query_mh.add_many(range(0, 20)) + query_ss = SourmashSignature(query_mh, name='query') + + match_mh_1 = query_mh.copy_and_clear() + match_mh_1.add_many(range(0, 10)) + match_ss_1 = SourmashSignature(match_mh_1, name='match1') + + # load up the counter + counter = counter_gather_constructor(query_ss) + counter.add(match_ss_1) + + # impossible threshold: + threshold_bp=30*query_ss.minhash.scaled + results = counter.peek(query_ss.minhash, threshold_bp=threshold_bp) + assert results == [] + + +def test_counter_gather_empty_counter(counter_gather_constructor): + # check empty counter + query_mh = sourmash.MinHash(n=0, ksize=31, scaled=1) + query_ss = SourmashSignature(query_mh, name='query') + + # empty counter! + counter = counter_gather_constructor(query_ss) + + assert counter.peek(query_ss.minhash) == [] diff --git a/tests/test_lca.py b/tests/test_lca.py index 28aa7862ea..8977f94763 100644 --- a/tests/test_lca.py +++ b/tests/test_lca.py @@ -9,7 +9,7 @@ import sourmash_tst_utils as utils import sourmash -from sourmash import load_one_signature, SourmashSignature +from sourmash import load_one_signature, SourmashSignature, sourmash_args from sourmash.search import make_jaccard_search_query from sourmash.lca import lca_utils @@ -152,6 +152,9 @@ def test_api_create_insert_bad_ident(): ksize=31) ss2 = sourmash.load_one_signature(utils.get_test_data('63.fa.sig'), ksize=31) + ss1 = ss1.to_mutable() + ss2 = ss2.to_mutable() + ss1.name = '' ss1.filename = '' ss2.name = '' @@ -335,10 +338,10 @@ def test_api_create_gather(): lca_db = sourmash.lca.LCA_Database(ksize=31, scaled=1000) lca_db.insert(ss) - results = lca_db.gather(ss, threshold_bp=0) - print(results) - assert len(results) == 1 - (similarity, match, filename) = results[0] + result = lca_db.best_containment(ss, threshold_bp=0) + print(result) + assert result + (similarity, match, filename) = result assert match.minhash == ss.minhash @@ -416,7 +419,36 @@ def test_api_create_insert_two_then_scale(): # downsample everything to 5000 lca_db.downsample_scaled(5000) + minhash = ss.minhash.downsample(scaled=5000) + minhash2 = ss2.minhash.downsample(scaled=5000) + + # & check... + combined_mins = set(minhash.hashes.keys()) + combined_mins.update(set(minhash2.hashes.keys())) + assert len(lca_db._hashval_to_idx) == len(combined_mins) + + +def test_api_create_insert_two_then_scale_then_add(): + # construct database, THEN downsample, then add another + ss = sourmash.load_one_signature(utils.get_test_data('47.fa.sig'), + ksize=31) + ss2 = sourmash.load_one_signature(utils.get_test_data('63.fa.sig'), + ksize=31) + + lca_db = sourmash.lca.LCA_Database(ksize=31, scaled=1000) + lca_db.insert(ss) + + # downsample everything to 5000 + lca_db.downsample_scaled(5000) + + # insert another after downsample + lca_db.insert(ss2) + + # now test - + ss = ss.to_mutable() ss.minhash = ss.minhash.downsample(scaled=5000) + + ss2 = ss2.to_mutable() ss2.minhash = ss2.minhash.downsample(scaled=5000) # & check... @@ -440,12 +472,12 @@ def test_api_create_insert_scale_two(): lca_db.insert(ss2) # downsample sigs to 5000 - ss.minhash = ss.minhash.downsample(scaled=5000) - ss2.minhash = ss2.minhash.downsample(scaled=5000) + minhash = ss.minhash.downsample(scaled=5000) + minhash2 = ss2.minhash.downsample(scaled=5000) # & check... - combined_mins = set(ss.minhash.hashes.keys()) - combined_mins.update(set(ss2.minhash.hashes.keys())) + combined_mins = set(minhash.hashes.keys()) + combined_mins.update(set(minhash2.hashes.keys())) assert len(lca_db._hashval_to_idx) == len(combined_mins) @@ -644,20 +676,22 @@ def test_search_db_scaled_gt_sig_scaled(): results = db.search(sig, threshold=.01, ignore_abundance=True) match_sig = results[0][1] - sig.minhash = sig.minhash.downsample(scaled=10000) - assert sig.minhash == match_sig.minhash + minhash = sig.minhash.downsample(scaled=10000) + assert minhash == match_sig.minhash def test_search_db_scaled_lt_sig_scaled(): dbfile = utils.get_test_data('lca/47+63.lca.json') db, ksize, scaled = lca_utils.load_single_database(dbfile) sig = sourmash.load_one_signature(utils.get_test_data('47.fa.sig')) + + sig = sig.to_mutable() sig.minhash = sig.minhash.downsample(scaled=100000) results = db.search(sig, threshold=.01, ignore_abundance=True) print(results) - assert results[0][0] == 1.0 - match = results[0][1] + assert results[0].score == 1.0 + match = results[0].signature orig_sig = sourmash.load_one_signature(utils.get_test_data('47.fa.sig')) assert orig_sig.minhash.jaccard(match.minhash, downsample=True) == 1.0 @@ -668,24 +702,24 @@ def test_gather_db_scaled_gt_sig_scaled(): db, ksize, scaled = lca_utils.load_single_database(dbfile) sig = sourmash.load_one_signature(utils.get_test_data('47.fa.sig')) - results = db.gather(sig, threshold=.01, ignore_abundance=True) - match_sig = results[0][1] + result = db.best_containment(sig, threshold=.01, ignore_abundance=True) + match_sig = result[1] - sig.minhash = sig.minhash.downsample(scaled=10000) - assert sig.minhash == match_sig.minhash + minhash = sig.minhash.downsample(scaled=10000) + assert minhash == match_sig.minhash def test_gather_db_scaled_lt_sig_scaled(): dbfile = utils.get_test_data('lca/47+63.lca.json') db, ksize, scaled = lca_utils.load_single_database(dbfile) sig = sourmash.load_one_signature(utils.get_test_data('47.fa.sig')) - sig.minhash = sig.minhash.downsample(scaled=100000) + sig_minhash = sig.minhash.downsample(scaled=100000) - results = db.gather(sig, threshold=.01, ignore_abundance=True) - match_sig = results[0][1] + result = db.best_containment(sig, threshold=.01, ignore_abundance=True) + match_sig = result[1] - match_sig.minhash = match_sig.minhash.downsample(scaled=100000) - assert sig.minhash == match_sig.minhash + minhash = match_sig.minhash.downsample(scaled=100000) + assert sig_minhash == minhash def test_db_lineage_to_lid(): @@ -1181,6 +1215,7 @@ def test_index_traverse_real_spreadsheet_report(runtmp, lca_db_format): def test_single_classify(runtmp): + # run a basic 'classify', check output. db1 = utils.get_test_data('lca/delmont-1.lca.json') input_sig = utils.get_test_data('lca/TARA_ASE_MAG_00031.sig') @@ -1196,6 +1231,28 @@ def test_single_classify(runtmp): assert 'loaded 1 LCA databases' in runtmp.last_result.err +def test_single_classify_zip_query(runtmp): + # run 'classify' with a query in a zipfile + db1 = utils.get_test_data('lca/delmont-1.lca.json') + input_sig = utils.get_test_data('lca/TARA_ASE_MAG_00031.sig') + + query_ss = sourmash.load_one_signature(input_sig, ksize=31) + query_zipfile = runtmp.output('query.zip') + with sourmash_args.SaveSignaturesToLocation(query_zipfile) as save_sig: + save_sig.add(query_ss) + + cmd = ['lca', 'classify', '--db', db1, '--query', query_zipfile] + runtmp.sourmash(*cmd) + + print(cmd) + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert 'TARA_ASE_MAG_00031,found,Bacteria,Proteobacteria,Gammaproteobacteria,Alteromonadales,Alteromonadaceae,Alteromonas,Alteromonas_macleodii' in runtmp.last_result.out + assert 'classified 1 signatures total' in runtmp.last_result.err + assert 'loaded 1 LCA databases' in runtmp.last_result.err + + def test_single_classify_to_output(runtmp): db1 = utils.get_test_data(f'lca/delmont-1.lca.json') input_sig = utils.get_test_data('lca/TARA_ASE_MAG_00031.sig') @@ -1838,6 +1895,28 @@ def test_single_summarize_scaled(runtmp): assert '100.0% 27 Bacteria;Proteobacteria;Gammaproteobacteria;Alteromonadales' +def test_single_summarize_scaled_zip_query(runtmp): + # check zipfile as query + db1 = utils.get_test_data('lca/delmont-1.lca.json') + input_sig = utils.get_test_data('lca/TARA_ASE_MAG_00031.sig') + + query_ss = sourmash.load_one_signature(input_sig, ksize=31) + query_zipfile = runtmp.output('query.zip') + with sourmash_args.SaveSignaturesToLocation(query_zipfile) as save_sig: + save_sig.add(query_ss) + + cmd = ['lca', 'summarize', '--db', db1, '--query', query_zipfile, + '--scaled', '100000'] + runtmp.sourmash(*cmd) + + print(cmd) + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert 'loaded 1 signatures from 1 files total.' in runtmp.last_result.err + assert '100.0% 27 Bacteria;Proteobacteria;Gammaproteobacteria;Alteromonadales' + + def test_multi_summarize_with_unassigned_singleton(runtmp, lca_db_format): taxcsv = utils.get_test_data('lca/delmont-6.csv') input_sig1 = utils.get_test_data('lca/TARA_ASE_MAG_00031.sig') @@ -1894,6 +1973,73 @@ def remove_line_startswith(x, check=None): assert not out_lines +def test_multi_summarize_with_zip_unassigned_singleton(runtmp, lca_db_format): + # test summarize on multiple queries, in a zipfile. + taxcsv = utils.get_test_data('lca/delmont-6.csv') + input_sig1 = utils.get_test_data('lca/TARA_ASE_MAG_00031.sig') + input_sig2 = utils.get_test_data('lca/TARA_PSW_MAG_00136.sig') + lca_db = runtmp.output(f'delmont-1.lca.{lca_db_format}') + + cmd = ['lca', 'index', taxcsv, lca_db, input_sig1, input_sig2, + '-F', lca_db_format] + runtmp.sourmash(*cmd) + + print(cmd) + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert os.path.exists(lca_db) + + assert "** assuming column 'MAGs' is identifiers in spreadsheet" in runtmp.last_result.err + assert "** assuming column 'Domain' is superkingdom in spreadsheet" in runtmp.last_result.err + assert '2 identifiers used out of 2 distinct identifiers in spreadsheet.' in runtmp.last_result.err + + query_zipfile = runtmp.output('query.zip') + with sourmash_args.SaveSignaturesToLocation(query_zipfile) as save_sig: + input_sig1 = utils.get_test_data('lca/TARA_ASE_MAG_00031.sig') + sig1 = sourmash.load_one_signature(input_sig1, ksize=31) + input_sig2 = utils.get_test_data('lca/TARA_PSW_MAG_00136.sig') + sig2 = sourmash.load_one_signature(input_sig2, ksize=31) + + save_sig.add(sig1) + save_sig.add(sig2) + + cmd = ['lca', 'summarize', '--db', lca_db, '--query', 'query.zip', + '--ignore-abundance'] + runtmp.sourmash(*cmd) + + print(cmd) + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert 'loaded 2 signatures from 1 files total.' in runtmp.last_result.err + + out_lines = runtmp.last_result.out.splitlines() + def remove_line_startswith(x, check=None): + for line in out_lines: + if line.startswith(x): + out_lines.remove(line) + if check: + # make sure the check value is in there + assert check in line + return line + assert 0, "couldn't find {}".format(x) + + # note, proportions/percentages are now per-file + remove_line_startswith('100.0% 200 Bacteria ', ':5b438c6c') + remove_line_startswith('100.0% 200 Bacteria;Proteobacteria;unassigned;unassigned ') + remove_line_startswith('100.0% 1231 Eukaryota;Chlorophyta ') + remove_line_startswith('100.0% 1231 Eukaryota ', ':db50b713') + remove_line_startswith('100.0% 200 Bacteria;Proteobacteria ') + remove_line_startswith('100.0% 200 Bacteria;Proteobacteria;unassigned ') + remove_line_startswith('100.0% 1231 Eukaryota;Chlorophyta;Prasinophyceae ') + remove_line_startswith('100.0% 200 Bacteria;Proteobacteria;unassigned;unassigned;Alteromonadaceae ') + remove_line_startswith('100.0% 1231 Eukaryota;Chlorophyta;Prasinophyceae;unassigned;unassigned ') + remove_line_startswith('100.0% 1231 Eukaryota;Chlorophyta;Prasinophyceae;unassigned ') + remove_line_startswith('100.0% 1231 Eukaryota;Chlorophyta;Prasinophyceae;unassigned;unassigned;Ostreococcus ') + assert not out_lines + + def test_summarize_to_root(runtmp, lca_db_format): taxcsv = utils.get_test_data('lca-root/tax.csv') input_sig1 = utils.get_test_data('lca-root/TARA_MED_MAG_00029.fa.sig') @@ -2010,7 +2156,7 @@ def test_summarize_unknown_hashes_abund(runtmp, lca_db_format): @utils.in_thisdir -def test_lca_summarize_abund_hmp(c): +def test_summarize_abund_hmp(c): # test lca summarize --with-abundance on some real data queryfile = utils.get_test_data('hmp-sigs/G36354.sig.gz') dbname = utils.get_test_data('hmp-sigs/G36354-matches.lca.json.gz') @@ -2021,7 +2167,7 @@ def test_lca_summarize_abund_hmp(c): @utils.in_thisdir -def test_lca_summarize_abund_fake_no_abund(c): +def test_summarize_abund_fake_no_abund(c): # test lca summarize on some known/fake data; see docs for explanation. queryfile = utils.get_test_data('fake-abund/query.sig.gz') dbname = utils.get_test_data('fake-abund/matches.lca.json.gz') @@ -2035,7 +2181,7 @@ def test_lca_summarize_abund_fake_no_abund(c): @utils.in_thisdir -def test_lca_summarize_abund_fake_yes_abund(c): +def test_summarize_abund_fake_yes_abund(c): # test lca summarize abundance weighting on some known/fake data queryfile = utils.get_test_data('fake-abund/query.sig.gz') dbname = utils.get_test_data('fake-abund/matches.lca.json.gz') @@ -2197,8 +2343,9 @@ def test_compare_csv_real(runtmp): assert '0 incompatible at rank species' in runtmp.last_result.err -def test_incompat_lca_db_ksize_2(runtmp, lca_db_format): - # test on gather - create a database with ksize of 25 +def test_incompat_lca_db_ksize_2_fail(runtmp, lca_db_format): + # test on gather - create a database with ksize of 25 => fail + # because of incompatibility. c = runtmp testdata1 = utils.get_test_data('lca/TARA_ASE_MAG_00031.fa.gz') c.run_sourmash('sketch', 'dna', '-p', 'k=25,scaled=1000', testdata1, @@ -2226,6 +2373,34 @@ def test_incompat_lca_db_ksize_2(runtmp, lca_db_format): assert "ksize on this database is 25; this is different from requested ksize of 31" +def test_incompat_lca_db_ksize_2_nofail(runtmp, lca_db_format): + # test on gather - create a database with ksize of 25, no fail + # because of --no-fail-on-empty-databases + c = runtmp + testdata1 = utils.get_test_data('lca/TARA_ASE_MAG_00031.fa.gz') + c.run_sourmash('sketch', 'dna', '-p', 'k=25,scaled=1000', testdata1, + '-o', 'test_db.sig') + print(c) + + c.run_sourmash('lca', 'index', utils.get_test_data('lca/delmont-1.csv',), + f'test.lca.{lca_db_format}', 'test_db.sig', + '-k', '25', '--scaled', '10000', + '-F', lca_db_format) + print(c) + + # this should not fail despite mismatched ksize, b/c of --no-fail flag. + c.run_sourmash('gather', utils.get_test_data('lca/TARA_ASE_MAG_00031.sig'), f'test.lca.{lca_db_format}', '--no-fail-on-empty-database') + + err = c.last_result.err + print(err) + + if lca_db_format == 'sql': + assert "no compatible signatures found in 'test.lca.sql'" in err + else: + assert "ERROR: cannot use 'test.lca.json' for this query." in err + assert "ksize on this database is 25; this is different from requested ksize of 31" + + def test_lca_index_empty(runtmp, lca_db_format): c = runtmp # test lca index with an empty taxonomy CSV, followed by a load & gather. @@ -2248,9 +2423,9 @@ def test_lca_index_empty(runtmp, lca_db_format): lca_db_filename = c.output(f'xxx.lca.{lca_db_format}') db, ksize, scaled = lca_utils.load_single_database(lca_db_filename) - results = db.gather(sig63) - assert len(results) == 1 - containment, match_sig, name = results[0] + result = db.best_containment(sig63) + assert result + containment, match_sig, name = result assert containment == 1.0 assert match_sig.minhash == sig63.minhash assert name == lca_db_filename @@ -2281,22 +2456,22 @@ def test_lca_gather_threshold_1(): # query with empty hashes assert not new_mh with pytest.raises(ValueError): - db.gather(SourmashSignature(new_mh)) + db.best_containment(SourmashSignature(new_mh)) # add one hash new_mh.add_hash(mins.pop()) assert len(new_mh) == 1 - results = db.gather(SourmashSignature(new_mh)) - assert len(results) == 1 - containment, match_sig, name = results[0] + result = db.best_containment(SourmashSignature(new_mh)) + assert result + containment, match_sig, name = result assert containment == 1.0 assert match_sig.minhash == sig2.minhash assert name == None # check with a threshold -> should be no results. with pytest.raises(ValueError): - db.gather(SourmashSignature(new_mh), threshold_bp=5000) + db.best_containment(SourmashSignature(new_mh), threshold_bp=5000) # add three more hashes => length of 4 new_mh.add_hash(mins.pop()) @@ -2304,16 +2479,16 @@ def test_lca_gather_threshold_1(): new_mh.add_hash(mins.pop()) assert len(new_mh) == 4 - results = db.gather(SourmashSignature(new_mh)) - assert len(results) == 1 - containment, match_sig, name = results[0] + result = db.best_containment(SourmashSignature(new_mh)) + assert result + containment, match_sig, name = result assert containment == 1.0 assert match_sig.minhash == sig2.minhash assert name == None # check with a too-high threshold -> should be no results. with pytest.raises(ValueError): - db.gather(SourmashSignature(new_mh), threshold_bp=5000) + db.best_containment(SourmashSignature(new_mh), threshold_bp=5000) def test_lca_gather_threshold_5(): @@ -2347,17 +2522,17 @@ def test_lca_gather_threshold_5(): new_mh.add_hash(mins.pop()) # should get a result with no threshold (any match at all is returned) - results = db.gather(SourmashSignature(new_mh)) - assert len(results) == 1 - containment, match_sig, name = results[0] + result = db.best_containment(SourmashSignature(new_mh)) + assert result + containment, match_sig, name = result assert containment == 1.0 assert match_sig.minhash == sig2.minhash assert name == None # now, check with a threshold_bp that should be meet-able. - results = db.gather(SourmashSignature(new_mh), threshold_bp=5000) - assert len(results) == 1 - containment, match_sig, name = results[0] + result = db.best_containment(SourmashSignature(new_mh), threshold_bp=5000) + assert result + containment, match_sig, name = result assert containment == 1.0 assert match_sig.minhash == sig2.minhash assert name == None @@ -2380,10 +2555,10 @@ def test_gather_multiple_return(): # now, run gather. how many results do we get, and are they in the # right order? - results = db.gather(sig63) - print(len(results)) - assert len(results) == 1 - assert results[0][0] == 1.0 + result = db.best_containment(sig63) + print(result) + assert result + assert result.score == 1.0 def test_lca_db_protein_build(): @@ -2408,8 +2583,8 @@ def test_lca_db_protein_build(): results = db.search(sig1, threshold=0.0) assert len(results) == 2 - results = db.gather(sig2) - assert results[0][0] == 1.0 + result = db.best_containment(sig2) + assert result.score == 1.0 @utils.in_tempdir @@ -2444,8 +2619,8 @@ def test_lca_db_protein_save_load(c): results = db2.search(sig1, threshold=0.0) assert len(results) == 2 - results = db2.gather(sig2) - assert results[0][0] == 1.0 + result = db2.best_containment(sig2) + assert result.score == 1.0 def test_lca_db_protein_command_index(runtmp, lca_db_format): @@ -2480,8 +2655,8 @@ def test_lca_db_protein_command_index(runtmp, lca_db_format): results = db2.search(sig1, threshold=0.0) assert len(results) == 2 - results = db2.gather(sig2) - assert results[0][0] == 1.0 + result = db2.best_containment(sig2) + assert result.score == 1.0 @utils.in_thisdir @@ -2492,7 +2667,7 @@ def test_lca_db_protein_command_search(c): db_out = utils.get_test_data('prot/protein.lca.json.gz') c.run_sourmash('search', sigfile1, db_out, '--threshold', '0.0') - assert '2 matches:' in c.last_result.out + assert '2 matches' in c.last_result.out c.run_sourmash('gather', sigfile1, db_out) assert 'found 1 matches total' in c.last_result.out @@ -2521,8 +2696,8 @@ def test_lca_db_hp_build(): results = db.search(sig1, threshold=0.0) assert len(results) == 2 - results = db.gather(sig2) - assert results[0][0] == 1.0 + result = db.best_containment(sig2) + assert result.score == 1.0 @utils.in_tempdir @@ -2555,8 +2730,8 @@ def test_lca_db_hp_save_load(c): results = db2.search(sig1, threshold=0.0) assert len(results) == 2 - results = db2.gather(sig2) - assert results[0][0] == 1.0 + result = db2.best_containment(sig2) + assert result.score == 1.0 def test_lca_db_hp_command_index(runtmp, lca_db_format): @@ -2591,8 +2766,8 @@ def test_lca_db_hp_command_index(runtmp, lca_db_format): results = db2.search(sig1, threshold=0.0) assert len(results) == 2 - results = db2.gather(sig2) - assert results[0][0] == 1.0 + result = db2.best_containment(sig2) + assert result.score == 1.0 @utils.in_thisdir @@ -2603,7 +2778,7 @@ def test_lca_db_hp_command_search(c): db_out = utils.get_test_data('prot/hp.lca.json.gz') c.run_sourmash('search', sigfile1, db_out, '--threshold', '0.0') - assert '2 matches:' in c.last_result.out + assert '2 matches' in c.last_result.out c.run_sourmash('gather', sigfile1, db_out, '--threshold', '0.0') assert 'found 1 matches total' in c.last_result.out @@ -2632,8 +2807,8 @@ def test_lca_db_dayhoff_build(): results = db.search(sig1, threshold=0.0) assert len(results) == 2 - results = db.gather(sig2) - assert results[0][0] == 1.0 + result = db.best_containment(sig2) + assert result.score == 1.0 @utils.in_tempdir @@ -2666,8 +2841,8 @@ def test_lca_db_dayhoff_save_load(c): results = db2.search(sig1, threshold=0.0) assert len(results) == 2 - results = db2.gather(sig2) - assert results[0][0] == 1.0 + result = db2.best_containment(sig2) + assert result.score == 1.0 def test_lca_db_dayhoff_command_index(runtmp, lca_db_format): @@ -2702,8 +2877,8 @@ def test_lca_db_dayhoff_command_index(runtmp, lca_db_format): results = db2.search(sig1, threshold=0.0) assert len(results) == 2 - results = db2.gather(sig2) - assert results[0][0] == 1.0 + result = db2.best_containment(sig2) + assert result.score == 1.0 @utils.in_thisdir @@ -2714,7 +2889,7 @@ def test_lca_db_dayhoff_command_search(c): db_out = utils.get_test_data('prot/dayhoff.lca.json.gz') c.run_sourmash('search', sigfile1, db_out, '--threshold', '0.0') - assert '2 matches:' in c.last_result.out + assert '2 matches' in c.last_result.out c.run_sourmash('gather', sigfile1, db_out, '--threshold', '0.0') assert 'found 1 matches total' in c.last_result.out diff --git a/tests/test_minhash.py b/tests/test_minhash.py index 6f43086ba0..05802c0bff 100644 --- a/tests/test_minhash.py +++ b/tests/test_minhash.py @@ -1,4 +1,4 @@ -# This file is part of sourmash, https://github.com/dib-lab/sourmash/, and is +# This file is part of sourmash, https://github.com/sourmash-bio/sourmash/, and is # Copyright (C) 2016, The Regents of the University of California. # # Redistribution and use in source and binary forms, with or without @@ -36,6 +36,7 @@ import itertools import pickle import math +import numpy as np import pytest @@ -215,7 +216,7 @@ def test_seq_to_hashes(track_abundance): def test_seq_to_hashes_protein_1(track_abundance, dayhoff): - mh = MinHash(10, 2, True, dayhoff=dayhoff, hp=False, track_abundance=track_abundance) + mh = MinHash(10, 2, is_protein=True, dayhoff=dayhoff, hp=False, track_abundance=track_abundance) prot_seq = "AGYYG" mh.add_protein(prot_seq) @@ -267,7 +268,7 @@ def test_seq_to_hashes_bad_kmers_as_zeroes_2(): def test_seq_to_hashes_translated_short(): - mh = MinHash(0, 2, True, dayhoff=True, hp=False, scaled = 1) + mh = MinHash(0, 2, is_protein=True, dayhoff=True, hp=False, scaled = 1) hashes = mh.seq_to_hashes("ACTGA") assert(len(hashes) == 0) @@ -275,7 +276,7 @@ def test_seq_to_hashes_translated_short(): def test_bytes_protein_dayhoff(track_abundance, dayhoff): # verify that we can hash protein/aa sequences - mh = MinHash(10, 2, True, dayhoff=dayhoff, hp=False, + mh = MinHash(10, 2, is_protein=True, dayhoff=dayhoff, hp=False, track_abundance=track_abundance) expected_moltype = 'protein' @@ -292,7 +293,7 @@ def test_bytes_protein_dayhoff(track_abundance, dayhoff): def test_protein_dayhoff(track_abundance, dayhoff): # verify that we can hash protein/aa sequences - mh = MinHash(10, 2, True, dayhoff=dayhoff, hp=False, track_abundance=track_abundance) + mh = MinHash(10, 2, is_protein=True, dayhoff=dayhoff, hp=False, track_abundance=track_abundance) mh.add_protein('AGYYG') assert len(mh.hashes) == 4 @@ -300,7 +301,7 @@ def test_protein_dayhoff(track_abundance, dayhoff): def test_bytes_protein_hp(track_abundance, hp): # verify that we can hash protein/aa sequences - mh = MinHash(10, 2, True, dayhoff=False, hp=hp, track_abundance=track_abundance) + mh = MinHash(10, 2, is_protein=True, dayhoff=False, hp=hp, track_abundance=track_abundance) expected_moltype = 'protein' if hp: expected_moltype = 'hp' @@ -318,7 +319,7 @@ def test_bytes_protein_hp(track_abundance, hp): def test_protein_hp(track_abundance, hp): # verify that we can hash protein/aa sequences - mh = MinHash(10, 2, True, dayhoff=False, hp=hp, track_abundance=track_abundance) + mh = MinHash(10, 2, is_protein=True, dayhoff=False, hp=hp, track_abundance=track_abundance) mh.add_protein('AGYYG') if hp: @@ -422,7 +423,7 @@ def test_hp_2(track_abundance): def test_protein_short(track_abundance): # verify that we can hash protein/aa sequences - mh = MinHash(10, 9, True, track_abundance=track_abundance) + mh = MinHash(10, 9, is_protein=True, track_abundance=track_abundance) mh.add_protein('AG') assert len(mh.hashes) == 0, mh.hashes @@ -817,17 +818,17 @@ def test_mh_count_common(track_abundance): def test_mh_count_common_diff_protein(track_abundance): - a = MinHash(20, 5, False, track_abundance=track_abundance) - b = MinHash(20, 5, True, track_abundance=track_abundance) + a = MinHash(20, 5, is_protein=False, track_abundance=track_abundance) + b = MinHash(20, 5, is_protein=True, track_abundance=track_abundance) with pytest.raises(ValueError): a.count_common(b) def test_mh_count_common_diff_maxhash(track_abundance): - a = MinHash(0, 5, False, track_abundance=track_abundance, + a = MinHash(0, 5, is_protein=False, track_abundance=track_abundance, scaled=_get_scaled_for_max_hash(1)) - b = MinHash(0, 5, True, track_abundance=track_abundance, + b = MinHash(0, 5, is_protein=True, track_abundance=track_abundance, scaled=_get_scaled_for_max_hash(2)) with pytest.raises(ValueError): @@ -835,8 +836,8 @@ def test_mh_count_common_diff_maxhash(track_abundance): def test_mh_count_common_diff_seed(track_abundance): - a = MinHash(20, 5, False, track_abundance=track_abundance, seed=1) - b = MinHash(20, 5, True, track_abundance=track_abundance, seed=2) + a = MinHash(20, 5, is_protein=False, track_abundance=track_abundance, seed=1) + b = MinHash(20, 5, is_protein=True, track_abundance=track_abundance, seed=2) with pytest.raises(ValueError): a.count_common(b) @@ -1091,8 +1092,8 @@ def test_mh_inplace_concat(track_abundance): def test_mh_merge_diff_protein(track_abundance): - a = MinHash(20, 5, False, track_abundance=track_abundance) - b = MinHash(20, 5, True, track_abundance=track_abundance) + a = MinHash(20, 5, is_protein=False, track_abundance=track_abundance) + b = MinHash(20, 5, is_protein=True, track_abundance=track_abundance) with pytest.raises(ValueError): a.merge(b) @@ -1107,8 +1108,8 @@ def test_mh_merge_diff_ksize(track_abundance): def test_mh_similarity_diff_protein(track_abundance): - a = MinHash(20, 5, False, track_abundance=track_abundance) - b = MinHash(20, 5, True, track_abundance=track_abundance) + a = MinHash(20, 5, is_protein=False, track_abundance=track_abundance) + b = MinHash(20, 5, is_protein=True, track_abundance=track_abundance) with pytest.raises(ValueError): a.similarity(b) @@ -1142,8 +1143,8 @@ def test_mh_compare_diff_max_hash(track_abundance): def test_mh_concat_diff_protein(track_abundance): - a = MinHash(20, 5, False, track_abundance=track_abundance) - b = MinHash(20, 5, True, track_abundance=track_abundance) + a = MinHash(20, 5, is_protein=False, track_abundance=track_abundance) + b = MinHash(20, 5, is_protein=True, track_abundance=track_abundance) with pytest.raises(ValueError): a += b @@ -1211,7 +1212,7 @@ def test_murmur(): def test_abundance_simple(): - a = MinHash(20, 5, False, track_abundance=True) + a = MinHash(20, 5, is_protein=False, track_abundance=True) a.add_sequence('AAAAA') assert list(a.hashes) == [2110480117637990133] @@ -1223,7 +1224,7 @@ def test_abundance_simple(): def test_add_hash_with_abundance(): - a = MinHash(20, 5, False, track_abundance=True) + a = MinHash(20, 5, is_protein=False, track_abundance=True) a.add_hash_with_abundance(10, 1) assert a.hashes == {10: 1} @@ -1236,7 +1237,7 @@ def test_add_hash_with_abundance(): def test_add_hash_with_abundance_2(): - a = MinHash(20, 5, False, track_abundance=False) + a = MinHash(20, 5, is_protein=False, track_abundance=False) with pytest.raises(RuntimeError) as e: a.add_hash_with_abundance(10, 1) @@ -1245,7 +1246,7 @@ def test_add_hash_with_abundance_2(): def test_clear(): - a = MinHash(20, 5, False, track_abundance=True) + a = MinHash(20, 5, is_protein=False, track_abundance=True) a.add_hash(10) assert a.hashes == {10: 1} @@ -1255,7 +1256,7 @@ def test_clear(): def test_clear_2(): - a = MinHash(20, 5, False, track_abundance=False) + a = MinHash(20, 5, is_protein=False, track_abundance=False) a.add_hash(10) assert list(a.hashes) == [10] @@ -1265,8 +1266,8 @@ def test_clear_2(): def test_abundance_simple_2(): - a = MinHash(20, 5, False, track_abundance=True) - b = MinHash(20, 5, False, track_abundance=True) + a = MinHash(20, 5, is_protein=False, track_abundance=True) + b = MinHash(20, 5, is_protein=False, track_abundance=True) a.add_sequence('AAAAA') assert list(a.hashes) == [2110480117637990133] @@ -1281,8 +1282,8 @@ def test_abundance_simple_2(): def test_abundance_count_common(): - a = MinHash(20, 5, False, track_abundance=True) - b = MinHash(20, 5, False, track_abundance=False) + a = MinHash(20, 5, is_protein=False, track_abundance=True) + b = MinHash(20, 5, is_protein=False, track_abundance=False) a.add_sequence('AAAAA') a.add_sequence('AAAAA') @@ -1351,8 +1352,8 @@ def test_set_abundance_2(): def test_set_abundance_clear(): # on empty minhash, clear should have no effect - a = MinHash(20, 5, False, track_abundance=True) - b = MinHash(20, 5, False, track_abundance=True) + a = MinHash(20, 5, is_protein=False, track_abundance=True) + b = MinHash(20, 5, is_protein=False, track_abundance=True) a.set_abundances({1: 3, 2: 4}, clear=True) b.set_abundances({1: 3, 2: 4}, clear=False) @@ -1362,7 +1363,7 @@ def test_set_abundance_clear(): def test_set_abundance_clear_2(): # default should be clear=True - a = MinHash(20, 5, False, track_abundance=True) + a = MinHash(20, 5, is_protein=False, track_abundance=True) a.add_hash(10) assert a.hashes == {10: 1} @@ -1372,7 +1373,7 @@ def test_set_abundance_clear_2(): def test_set_abundance_clear_3(): - a = MinHash(20, 5, False, track_abundance=True) + a = MinHash(20, 5, is_protein=False, track_abundance=True) a.add_hash(10) assert a.hashes == {10: 1} @@ -1384,7 +1385,7 @@ def test_set_abundance_clear_3(): def test_set_abundance_clear_4(): # setting the abundance of an already set hash should add # the abundances together - a = MinHash(20, 5, False, track_abundance=True) + a = MinHash(20, 5, is_protein=False, track_abundance=True) a.set_abundances({20: 2, 10: 1}, clear=False) # should also sort the hashes assert a.hashes == {10: 1, 20: 2} @@ -1481,6 +1482,69 @@ def test_scaled_property(track_abundance): assert a.scaled == scaled +def test_pickle_protein(track_abundance): + # check that protein/etc ksize is handled properly during serialization. + a = MinHash(0, 10, track_abundance=track_abundance, is_protein=True, + scaled=_get_scaled_for_max_hash(20)) + for i in range(0, 40, 2): + a.add_hash(i) + + b = pickle.loads(pickle.dumps(a)) + assert a.ksize == b.ksize + assert b.num == a.num + assert b._max_hash == a._max_hash + assert b._max_hash == 20 + assert b.is_protein + assert b.track_abundance == track_abundance + assert b.seed == a.seed + assert len(b.hashes) == len(a.hashes) + assert len(b.hashes) == 11 + assert a.scaled == b.scaled + assert b.scaled != 0 + + +def test_pickle_dayhoff(track_abundance): + # check that dayhoff ksize is handled properly during serialization. + a = MinHash(0, 10, track_abundance=track_abundance, dayhoff=True, + scaled=_get_scaled_for_max_hash(20)) + for i in range(0, 40, 2): + a.add_hash(i) + + b = pickle.loads(pickle.dumps(a)) + assert a.ksize == b.ksize + assert b.num == a.num + assert b._max_hash == a._max_hash + assert b._max_hash == 20 + assert b.dayhoff + assert b.track_abundance == track_abundance + assert b.seed == a.seed + assert len(b.hashes) == len(a.hashes) + assert len(b.hashes) == 11 + assert a.scaled == b.scaled + assert b.scaled != 0 + + +def test_pickle_hp(track_abundance): + # check that hp ksize is handled properly during serialization. + a = MinHash(0, 10, track_abundance=track_abundance, hp=True, + scaled=_get_scaled_for_max_hash(20)) + for i in range(0, 40, 2): + a.add_hash(i) + + b = pickle.loads(pickle.dumps(a)) + assert a.ksize == b.ksize + assert b.num == a.num + assert b._max_hash == a._max_hash + assert b._max_hash == 20 + assert b.hp + assert b.track_abundance == track_abundance + assert b.seed == a.seed + assert len(b.hashes) == len(a.hashes) + assert len(b.hashes) == 11 + assert a.scaled == b.scaled + assert b.scaled != 0 + + def test_pickle_max_hash(track_abundance): a = MinHash(0, 10, track_abundance=track_abundance, scaled=_get_scaled_for_max_hash(20)) @@ -2351,6 +2415,7 @@ def test_avg_containment_equal(): assert mh1.avg_containment(mh2) == 1 assert mh2.avg_containment(mh1) == 1 + def test_frozen_and_mutable_1(track_abundance): # mutable minhashes -> mutable minhashes creates new copy mh1 = MinHash(0, 21, scaled=1, track_abundance=track_abundance) @@ -2827,16 +2892,6 @@ def test_containment_ANI(): m1_cont_m2 = mh1.containment_ani(mh2, estimate_ci =True) m2_cont_m1 = mh2.containment_ani(mh1, estimate_ci =True) - print("\nmh1 contained by mh2", m1_cont_m2) - print("mh2 contained by mh1", m2_cont_m1) - - # first, assess as-is. ANI should be None, bc 2.fa.sig size is inaccurate - assert m1_cont_m2.ani == m2_cont_m1.ani == None - - # since size is inaccurate on 2.fa.sig, need to override to be able to get ani - m1_cont_m2.size_is_inaccurate = False - m2_cont_m1.size_is_inaccurate = False - print("\nmh1 contained by mh2", m1_cont_m2) print("mh2 contained by mh1", m2_cont_m1) @@ -2928,14 +2983,6 @@ def test_jaccard_ANI(): m1_jani_m2 = mh1.jaccard_ani(mh2) m2_jani_m1 = mh2.jaccard_ani(mh1) - # first, assess as-is. ANI should be 0, bc 2.fa.sig size is inaccurate - assert m1_jani_m2 == m2_jani_m1 - assert (m1_jani_m2.ani, m1_jani_m2.p_nothing_in_common, m1_jani_m2.jaccard_error) == (None, 0.0, 3.891666770716877e-07) - - # since size is inaccurate on 2.fa.sig, need to override to be able to get ani - m1_jani_m2.size_is_inaccurate = False - m2_jani_m1.size_is_inaccurate = False - assert m1_jani_m2 == m2_jani_m1 assert (m1_jani_m2.ani, m1_jani_m2.p_nothing_in_common, m1_jani_m2.jaccard_error) == (0.9783711630110239, 0.0, 3.891666770716877e-07) @@ -2993,6 +3040,7 @@ def test_jaccard_ANI_downsample(): ds_j_manual = mh1.jaccard_ani(mh2) assert ds_s1c == ds_s2c == ds_j_manual + def test_containment_ani_ci_tiny_testdata(): """ tiny test data to trigger the following: @@ -3007,13 +3055,31 @@ def test_containment_ani_ci_tiny_testdata(): m2_cani_m1 = mh2.containment_ani(mh1, estimate_ci=True) print(m2_cani_m1) - assert m2_cani_m1.ani == None + # from the formula ANI = c^(1/k) for c=3/4 and k=21 + np.testing.assert_almost_equal(m2_cani_m1.ani, 0.986394259982259, decimal=3) m2_cani_m1.size_is_inaccurate = False - assert m2_cani_m1.ani == 0.986394259982259 assert m2_cani_m1.ani_low == None assert m2_cani_m1.ani_high == None +def test_containment_num_fail(): + f1 = utils.get_test_data('num/47.fa.sig') + f2 = utils.get_test_data('num/63.fa.sig') + mh1 = sourmash.load_one_signature(f1, ksize=31).minhash + mh2 = sourmash.load_one_signature(f2, ksize=31).minhash + + with pytest.raises(TypeError) as exc: + mh1.contained_by(mh2) + print(str(exc)) + assert "Error: can only calculate containment for scaled MinHashes" in str(exc) + with pytest.raises(TypeError) as exc: + mh1.max_containment(mh2) + assert "Error: can only calculate containment for scaled MinHashes" in str(exc) + with pytest.raises(TypeError) as exc: + mh1.avg_containment(mh2) + assert "Error: can only calculate containment for scaled MinHashes" in str(exc) + + def test_ANI_num_fail(): f1 = utils.get_test_data('num/47.fa.sig') f2 = utils.get_test_data('num/63.fa.sig') @@ -3043,9 +3109,10 @@ def test_minhash_set_size_estimate_is_accurate(): f2 = utils.get_test_data('2+63.fa.sig') mh1 = sourmash.load_one_signature(f1, ksize=31).minhash mh2 = sourmash.load_one_signature(f2).minhash - - # check accuracy using default thresholds (rel_err= 0.5, confidence=0.95) - assert mh1.size_is_accurate() == False + mh1_ds = mh1.downsample(scaled=100000) + # check accuracy using default thresholds (rel_err= 0.2, confidence=0.95) + assert mh1.size_is_accurate() == True + assert mh1_ds.size_is_accurate() == False assert mh2.size_is_accurate() == True # change rel err @@ -3054,7 +3121,7 @@ def test_minhash_set_size_estimate_is_accurate(): # change prob assert mh1.size_is_accurate(confidence=0.5) == True - assert mh2.size_is_accurate(confidence=1) == False + assert mh1.size_is_accurate(relative_error=0.001, confidence=1) == False # check that relative error and confidence must be between 0 and 1 with pytest.raises(ValueError) as exc: @@ -3071,28 +3138,30 @@ def test_minhash_set_size_estimate_is_accurate(): def test_minhash_ani_inaccurate_size_est(): + # TODO: It's actually really tricky to get the set size to be inaccurate. Eg. For a scale factor of 10000, + # you would need f1 = utils.get_test_data('2.fa.sig') f2 = utils.get_test_data('2+63.fa.sig') - f3 = utils.get_test_data('47+63.fa.sig') mh1 = sourmash.load_one_signature(f1, ksize=31).minhash mh2 = sourmash.load_one_signature(f2).minhash - mh3 = sourmash.load_one_signature(f3).minhash - - assert mh1.size_is_accurate() == False + # downsample + mh1_ds = mh1.downsample(scaled=100000) + mh2_ds = mh2.downsample(scaled=100000) + assert mh1.size_is_accurate(relative_error=0.05, confidence=0.95) == True + assert mh1.size_is_accurate() == True + assert mh1_ds.size_is_accurate() == False assert mh2.size_is_accurate() == True - assert mh3.size_is_accurate() == True - assert mh1.jaccard_ani(mh2).ani == None - assert round(mh2.jaccard_ani(mh3).ani, 3) == 0.987 + assert round(mh1.jaccard_ani(mh2).ani, 3) == 0.978 - m1_ca_m2 = mh1.containment_ani(mh2) - assert m1_ca_m2.ani == None - assert m1_ca_m2.size_is_inaccurate == True + m2_ca_m1 = mh2.containment_ani(mh1) + assert round(m2_ca_m1.ani, 3) == 0.966 + assert m2_ca_m1.size_is_inaccurate == False - m2_ca_m3 = mh2.containment_ani(mh3) - print(m2_ca_m3) - assert round(m2_ca_m3.ani,3) == 0.987 - assert m2_ca_m3.size_is_inaccurate == False + m1_ca_m2_ds = mh1_ds.containment_ani(mh2_ds) + print(m1_ca_m2_ds) + assert m1_ca_m2_ds.ani == None #0.987 + assert m1_ca_m2_ds.size_is_inaccurate == True def test_size_num_fail(): diff --git a/tests/test_picklist.py b/tests/test_picklist.py new file mode 100644 index 0000000000..aba2d32d95 --- /dev/null +++ b/tests/test_picklist.py @@ -0,0 +1,23 @@ +""" +Tests for the picklist API. +""" +import pytest +import sourmash + +import sourmash_tst_utils as utils +from sourmash.picklist import SignaturePicklist + + +def test_load_empty_picklist_fail(): + empty = utils.get_test_data('picklist/empty.csv') + + pl = SignaturePicklist('manifest') + with pytest.raises(ValueError): + pl.load(empty, 'foo', allow_empty=False) + + +def test_load_empty_picklist_allow(): + empty = utils.get_test_data('picklist/empty.csv') + + pl = SignaturePicklist('manifest') + pl.load(empty, 'foo', allow_empty=True) diff --git a/tests/test_plugin_framework.py b/tests/test_plugin_framework.py new file mode 100644 index 0000000000..06156e4d85 --- /dev/null +++ b/tests/test_plugin_framework.py @@ -0,0 +1,541 @@ +""" +Test the plugin framework in sourmash.plugins, which uses importlib.metadata +entrypoints. +""" + +import sys +import pytest +import collections + +import sourmash +from sourmash.logging import set_quiet + +import sourmash_tst_utils as utils +from sourmash import plugins +from sourmash.index import LinearIndex +from sourmash.save_load import (Base_SaveSignaturesToLocation, + SaveSignaturesToLocation) + + +_Dist = collections.namedtuple('_Dist', ['version']) +class FakeEntryPoint: + """ + A class that stores a name and an object to be returned on 'load()'. + Mocks the EntryPoint class used by importlib.metadata. + """ + module = 'test_plugin_framework' + dist = _Dist('0.1') + group = 'groupfoo' + + def __init__(self, name, load_obj, *, + error_on_import=None): + self.name = name + self.load_obj = load_obj + self.error_on_import = error_on_import + + def load(self): + if self.error_on_import is not None: + raise self.error_on_import("as requested") + return self.load_obj + +# +# Test basic features of the load_from plugin hook. +# + +class Test_EntryPointBasics_LoadFrom: + def get_some_sigs(self, location, *args, **kwargs): + ss2 = utils.get_test_data('2.fa.sig') + ss47 = utils.get_test_data('47.fa.sig') + ss63 = utils.get_test_data('63.fa.sig') + + sig2 = sourmash.load_one_signature(ss2, ksize=31) + sig47 = sourmash.load_one_signature(ss47, ksize=31) + sig63 = sourmash.load_one_signature(ss63, ksize=31) + + lidx = LinearIndex([sig2, sig47, sig63], location) + + return lidx + get_some_sigs.priority = 1 + + def setup_method(self): + self.saved_plugins = plugins._plugin_load_from + plugins._plugin_load_from = [FakeEntryPoint('test_load', self.get_some_sigs), + FakeEntryPoint('test_load', self.get_some_sigs, error_on_import=ModuleNotFoundError)] + + def teardown_method(self): + plugins._plugin_load_from = self.saved_plugins + + def test_load_1(self): + ps = list(plugins.get_load_from_functions()) + assert len(ps) == 1 + + def test_load_2(self, runtmp): + fake_location = runtmp.output('passed-through location') + idx = sourmash.load_file_as_index(fake_location) + print(idx, idx.location) + + assert len(idx) == 3 + assert idx.location == fake_location + + +class Test_EntryPoint_LoadFrom_Priority: + def get_some_sigs(self, location, *args, **kwargs): + ss2 = utils.get_test_data('2.fa.sig') + ss47 = utils.get_test_data('47.fa.sig') + ss63 = utils.get_test_data('63.fa.sig') + + sig2 = sourmash.load_one_signature(ss2, ksize=31) + sig47 = sourmash.load_one_signature(ss47, ksize=31) + sig63 = sourmash.load_one_signature(ss63, ksize=31) + + lidx = LinearIndex([sig2, sig47, sig63], location) + + return lidx + get_some_sigs.priority = 5 + + def set_called_flag_1(self, location, *args, **kwargs): + # high priority 1, raise ValueError + print('setting flag 1') + self.was_called_flag_1 = True + raise ValueError + set_called_flag_1.priority = 1 + + def set_called_flag_2(self, location, *args, **kwargs): + # high priority 2, return None + print('setting flag 2') + self.was_called_flag_2 = True + + return None + set_called_flag_2.priority = 2 + + def set_called_flag_3(self, location, *args, **kwargs): + # lower priority 10, should not be called + print('setting flag 3') + self.was_called_flag_3 = True + + return None + set_called_flag_3.priority = 10 + + def setup_method(self): + self.saved_plugins = plugins._plugin_load_from + plugins._plugin_load_from = [ + FakeEntryPoint('test_load', self.get_some_sigs), + FakeEntryPoint('test_load_2', self.set_called_flag_1), + FakeEntryPoint('test_load_3', self.set_called_flag_2), + FakeEntryPoint('test_load_4', self.set_called_flag_3) + ] + self.was_called_flag_1 = False + self.was_called_flag_2 = False + self.was_called_flag_3 = False + + def teardown_method(self): + plugins._plugin_load_from = self.saved_plugins + + def test_load_1(self): + ps = list(plugins.get_load_from_functions()) + assert len(ps) == 4 + + assert not self.was_called_flag_1 + assert not self.was_called_flag_2 + assert not self.was_called_flag_3 + + def test_load_2(self, runtmp): + fake_location = runtmp.output('passed-through location') + idx = sourmash.load_file_as_index(fake_location) + print(idx, idx.location) + + assert len(idx) == 3 + assert idx.location == fake_location + + assert self.was_called_flag_1 + assert self.was_called_flag_2 + assert not self.was_called_flag_3 + + +# +# Test basic features of the save_to plugin hook. +# + +class FakeSaveClass(Base_SaveSignaturesToLocation): + """ + A fake save class that just records what was sent to it. + """ + priority = 50 + + def __init__(self, location): + super().__init__(location) + self.keep = [] + + @classmethod + def matches(cls, location): + if location: + return location.endswith('.this-is-a-test') + + def add(self, ss): + super().add(ss) + self.keep.append(ss) + + +class FakeSaveClass_HighPriority(FakeSaveClass): + priority = 1 + + +class Test_EntryPointBasics_SaveTo: + # test the basics + def setup_method(self): + self.saved_plugins = plugins._plugin_save_to + plugins._plugin_save_to = [FakeEntryPoint('test_save', FakeSaveClass), + FakeEntryPoint('test_save', FakeSaveClass, error_on_import=ModuleNotFoundError)] + + def teardown_method(self): + plugins._plugin_save_to = self.saved_plugins + + def test_save_1(self): + ps = list(plugins.get_save_to_functions()) + print(ps) + assert len(ps) == 1 + + def test_save_2(self, runtmp): + # load some signatures to save + ss2 = utils.get_test_data('2.fa.sig') + ss47 = utils.get_test_data('47.fa.sig') + ss63 = utils.get_test_data('63.fa.sig') + + sig2 = sourmash.load_one_signature(ss2, ksize=31) + sig47 = sourmash.load_one_signature(ss47, ksize=31) + sig63 = sourmash.load_one_signature(ss63, ksize=31) + + # build a fake location that matches the FakeSaveClass + # extension + fake_location = runtmp.output('out.this-is-a-test') + + # this should use the plugin architecture to return an object + # of type FakeSaveClass, with the three signatures in it. + x = SaveSignaturesToLocation(fake_location) + with x as save_sig: + save_sig.add(sig2) + save_sig.add(sig47) + save_sig.add(sig63) + + print(len(x)) + print(x.keep) + + assert isinstance(x, FakeSaveClass) + assert x.keep == [sig2, sig47, sig63] + + +class Test_EntryPointPriority_SaveTo: + # test that priority is observed + + def setup_method(self): + self.saved_plugins = plugins._plugin_save_to + plugins._plugin_save_to = [ + FakeEntryPoint('test_save', FakeSaveClass), + FakeEntryPoint('test_save2', FakeSaveClass_HighPriority), + ] + + def teardown_method(self): + plugins._plugin_save_to = self.saved_plugins + + def test_save_1(self): + ps = list(plugins.get_save_to_functions()) + print(ps) + assert len(ps) == 2 + + def test_save_2(self, runtmp): + # load some signatures to save + ss2 = utils.get_test_data('2.fa.sig') + ss47 = utils.get_test_data('47.fa.sig') + ss63 = utils.get_test_data('63.fa.sig') + + sig2 = sourmash.load_one_signature(ss2, ksize=31) + sig47 = sourmash.load_one_signature(ss47, ksize=31) + sig63 = sourmash.load_one_signature(ss63, ksize=31) + + # build a fake location that matches the FakeSaveClass + # extension + fake_location = runtmp.output('out.this-is-a-test') + + # this should use the plugin architecture to return an object + # of type FakeSaveClass, with the three signatures in it. + x = SaveSignaturesToLocation(fake_location) + with x as save_sig: + save_sig.add(sig2) + save_sig.add(sig47) + save_sig.add(sig63) + + print(len(x)) + print(x.keep) + + assert isinstance(x, FakeSaveClass_HighPriority) + assert x.keep == [sig2, sig47, sig63] + assert x.priority == 1 + + +# +# Test basic features of the save_to plugin hook. +# + +class FakeCommandClass(plugins.CommandLinePlugin): + """ + A fake CLI class. + """ + command = 'nifty' + description = "do somethin' nifty" + + def __init__(self, parser): + super().__init__(parser) + parser.add_argument('arg1') + parser.add_argument('--other', action='store_true') + parser.add_argument('--do-fail', action='store_true') + + def main(self, args): + super().main(args) + print(f"hello, world! argument is: {args.arg1}") + print(f"other is {args.other}") + + if args.do_fail: + return 1 + return 0 + + +class Test_EntryPointBasics_Command: + # test the basics + def setup_method(self): + _ = plugins.get_cli_script_plugins() + self.saved_plugins = plugins._plugin_cli + plugins._plugin_cli_once = False + plugins._plugin_cli = [FakeEntryPoint('test_command', + FakeCommandClass)] + + def teardown_method(self): + plugins._plugin_cli = self.saved_plugins + + def test_empty(self, runtmp): + # empty out script plugins... + plugins._plugin_cli = [] + + with pytest.raises(utils.SourmashCommandFailed): + runtmp.sourmash('scripts') + out = runtmp.last_result.out + err = runtmp.last_result.err + print(out) + print(err) + assert '(No script plugins detected!)' in out + + def test_cmd_0(self, runtmp): + # test default output with some plugins + with pytest.raises(utils.SourmashCommandFailed): + runtmp.sourmash('scripts') + + out = runtmp.last_result.out + err = runtmp.last_result.err + print(out) + print(err) + assert "do somethin' nifty" in out + assert "sourmash scripts nifty" in out + + def test_cmd_1(self): + # test descriptions + ps = list(plugins.get_cli_scripts_descriptions()) + print(ps) + assert len(ps) == 1 + + descr0 = ps[0] + assert "do somethin' nifty" in descr0 + assert "sourmash scripts nifty" in descr0 + + def test_cmd_2(self): + # test get_cli_script_plugins function + ps = list(plugins.get_cli_script_plugins()) + print(ps) + assert len(ps) == 1 + + def test_cmd_3(self, runtmp): + # test ability to run 'nifty' ;) + with pytest.raises(utils.SourmashCommandFailed): + runtmp.sourmash('scripts', 'nifty') + + out = runtmp.last_result.out + err = runtmp.last_result.err + print(out) + print(err) + + assert 'nifty: error: the following arguments are required: arg1' in err + assert 'usage: nifty [-h] [-q] [-d] [--other] [--do-fail] arg1' in err + + def test_cmd_4(self, runtmp): + # test basic argument parsing etc + runtmp.sourmash('scripts', 'nifty', '--other', 'some arg') + + out = runtmp.last_result.out + err = runtmp.last_result.err + print(out) + print(err) + + assert 'other is True' in out + assert 'hello, world! argument is: some arg' in out + + def test_cmd_5(self, runtmp): + # test exit code passthru + with pytest.raises(utils.SourmashCommandFailed): + runtmp.sourmash('scripts', 'nifty', '--do-fail', 'some arg') + + status = runtmp.last_result.status + out = runtmp.last_result.out + err = runtmp.last_result.err + print(out) + print(err) + print(status) + + assert 'other is False' in out + assert 'hello, world! argument is: some arg' in out + + +class FakeCommandClass_Second(plugins.CommandLinePlugin): + """ + A fake CLI class. + """ + command = 'more_nifty' + description = "do somethin' else nifty" + + def __init__(self, parser): + super().__init__(parser) + parser.add_argument('arg1') + parser.add_argument('--other', action='store_true') + parser.add_argument('--do-fail', action='store_true') + + def main(self, args): + super().main(args) + print(f"hello, world! argument is: {args.arg1}") + print(f"other is {args.other}") + + if args.do_fail: + return 1 + return 0 + + +class FakeCommandClass_Broken_1: + """ + A fake CLI class. + """ + # command = 'more_nifty' # no command + + def __init__(self, parser): + assert 0 + + def main(self, args): + assert 0 + + +class FakeCommandClass_Broken_2: + """ + A fake CLI class. + """ + command = 'broken' + # no description + + def __init__(self, parser): + pass + + def main(self, args): + return 0 + + +class Test_EntryPointBasics_TwoCommands: + # test a second command + def setup_method(self): + _ = plugins.get_cli_script_plugins() + self.saved_plugins = plugins._plugin_cli + plugins._plugin_cli_once = False + plugins._plugin_cli = [FakeEntryPoint('test_command', + FakeCommandClass), + FakeEntryPoint('test_command2', + FakeCommandClass_Second), + FakeEntryPoint('test_command3', + FakeCommandClass_Broken_1), + FakeEntryPoint('test_command4', + FakeCommandClass_Broken_2), + FakeEntryPoint('error-on-import', + FakeCommandClass, + error_on_import=ModuleNotFoundError) + ] + + def teardown_method(self): + plugins._plugin_cli = self.saved_plugins + + def test_cmd_0(self, runtmp): + # test default output for a few plugins + with pytest.raises(utils.SourmashCommandFailed): + runtmp.sourmash('scripts') + + out = runtmp.last_result.out + err = runtmp.last_result.err + print(out) + print(err) + assert "do somethin' nifty" in out + assert "sourmash scripts nifty" in out + + assert "do somethin' else nifty" in out + assert "sourmash scripts more_nifty" in out + + def test_cmd_1(self, runtmp): + # test 'nifty' + runtmp.sourmash('scripts', 'nifty', 'some arg') + + status = runtmp.last_result.status + out = runtmp.last_result.out + err = runtmp.last_result.err + print(out) + print(err) + print(status) + + assert 'other is False' in out + assert 'hello, world! argument is: some arg' in out + + def test_cmd_2(self, runtmp): + # test 'more_nifty' + runtmp.sourmash('scripts', 'more_nifty', 'some arg') + + status = runtmp.last_result.status + out = runtmp.last_result.out + err = runtmp.last_result.err + print(out) + print(err) + print(status) + + assert 'other is False' in out + assert 'hello, world! argument is: some arg' in out + + def test_sourmash_info(self, runtmp): + # test 'sourmash info -v' => shows the plugins + runtmp.sourmash('info', '-v') + + out = runtmp.last_result.out + err = runtmp.last_result.err + print(out) + print(err) + + expected = """ +groupfoo test_plugin_framework 0.1 test_command +groupfoo test_plugin_framework 0.1 test_command2 +groupfoo test_plugin_framework 0.1 test_command3 +groupfoo test_plugin_framework 0.1 test_command4 +""".splitlines() + for line in expected: + assert line in err + + +def test_cli_scripts_getattr_fail(): + # test scripts.__getattr__ w/fail + from sourmash.cli import scripts + + with pytest.raises(AttributeError): + scripts.ThisAttrDoesNotExist + + +def test_cli_scripts_getattr_succ(): + # test scripts.__getattr__ w/success + from sourmash.cli import scripts + + scripts.subparser diff --git a/tests/test_prefetch.py b/tests/test_prefetch.py index 50c96b4a69..7ab2d2c1dd 100644 --- a/tests/test_prefetch.py +++ b/tests/test_prefetch.py @@ -3,6 +3,7 @@ """ import os import csv +import gzip import pytest import glob import random @@ -11,7 +12,7 @@ import sourmash_tst_utils as utils import sourmash from sourmash_tst_utils import SourmashCommandFailed -from sourmash import signature +from sourmash import signature, sourmash_args def approx_eq(val1, val2): @@ -39,7 +40,11 @@ def test_prefetch_basic(runtmp, linear_gather): assert "WARNING: no output(s) specified! Nothing will be saved from this prefetch!" in c.last_result.err assert "selecting specified query k=31" in c.last_result.err assert "loaded query: NC_009665.1 Shewanella baltica... (k=31, DNA)" in c.last_result.err - assert "all sketches will be downsampled to scaled=1000" in c.last_result.err + assert "query sketch has scaled=1000; will be dynamically downsampled as needed" in c.last_result.err + + err = c.last_result.err + assert "loaded 5 total signatures from 3 locations." in err + assert "after selecting signatures compatible with search, 3 remain." in err assert "total of 2 matching signatures." in c.last_result.err assert "of 5177 distinct query hashes, 5177 were found in matches above threshold." in c.last_result.err @@ -141,7 +146,7 @@ def test_prefetch_query_abund(runtmp, linear_gather): assert "WARNING: no output(s) specified! Nothing will be saved from this prefetch!" in c.last_result.err assert "selecting specified query k=31" in c.last_result.err assert "loaded query: NC_009665.1 Shewanella baltica... (k=31, DNA)" in c.last_result.err - assert "all sketches will be downsampled to scaled=1000" in c.last_result.err + assert "query sketch has scaled=1000; will be dynamically downsampled as needed" in c.last_result.err assert "total of 2 matching signatures." in c.last_result.err assert "of 5177 distinct query hashes, 5177 were found in matches above threshold." in c.last_result.err @@ -167,7 +172,7 @@ def test_prefetch_subj_abund(runtmp, linear_gather): assert "WARNING: no output(s) specified! Nothing will be saved from this prefetch!" in c.last_result.err assert "selecting specified query k=31" in c.last_result.err assert "loaded query: NC_009665.1 Shewanella baltica... (k=31, DNA)" in c.last_result.err - assert "all sketches will be downsampled to scaled=1000" in c.last_result.err + assert "query sketch has scaled=1000; will be dynamically downsampled as needed" in c.last_result.err assert "total of 2 matching signatures." in c.last_result.err assert "of 5177 distinct query hashes, 5177 were found in matches above threshold." in c.last_result.err @@ -201,6 +206,33 @@ def test_prefetch_csv_out(runtmp, linear_gather): assert int(row['intersect_bp']) == expected +def test_prefetch_csv_gz_out(runtmp, linear_gather): + c = runtmp + + # test a basic prefetch, with CSV output to a .gz file + sig2 = utils.get_test_data('2.fa.sig') + sig47 = utils.get_test_data('47.fa.sig') + sig63 = utils.get_test_data('63.fa.sig') + + csvout = c.output('out.csv.gz') + + c.run_sourmash('prefetch', '-k', '31', sig47, sig63, sig2, sig47, + '-o', csvout, linear_gather) + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + + assert c.last_result.status == 0 + assert os.path.exists(csvout) + + expected_intersect_bp = [2529000, 5177000] + with gzip.open(csvout, 'rt', newline="") as fp: + r = csv.DictReader(fp) + for (row, expected) in zip(r, expected_intersect_bp): + print(row) + assert int(row['intersect_bp']) == expected + + def test_prefetch_matches(runtmp, linear_gather): c = runtmp @@ -425,7 +457,7 @@ def test_prefetch_no_num_subj(runtmp, linear_gather): print(c.last_result.err) assert c.last_result.status != 0 - assert "ERROR in prefetch: no compatible signatures in any databases?!" in c.last_result.err + assert "ERROR in prefetch: after picklists and patterns, no signatures to search!?" in c.last_result.err def test_prefetch_db_fromfile(runtmp, linear_gather): @@ -454,7 +486,7 @@ def test_prefetch_db_fromfile(runtmp, linear_gather): assert "WARNING: no output(s) specified! Nothing will be saved from this prefetch!" in c.last_result.err assert "selecting specified query k=31" in c.last_result.err assert "loaded query: NC_009665.1 Shewanella baltica... (k=31, DNA)" in c.last_result.err - assert "all sketches will be downsampled to scaled=1000" in c.last_result.err + assert "query sketch has scaled=1000; will be dynamically downsampled as needed" in c.last_result.err assert "total of 2 matching signatures." in c.last_result.err assert "of 5177 distinct query hashes, 5177 were found in matches above threshold." in c.last_result.err @@ -807,3 +839,28 @@ def test_prefetch_ani_csv_out_estimate_ci(runtmp, linear_gather): assert approx_eq(row['max_containment_ani'], expected['mc_ani']) assert approx_eq(row['average_containment_ani'], expected['ac_ani']) assert row['potential_false_negative'] == expected['pfn'] + + +def test_prefetch_ani_containment_asymmetry(runtmp): + # test contained_by asymmetries, viz #2215 + query_sig = utils.get_test_data('47.fa.sig') + merged_sig = utils.get_test_data('47-63-merge.sig') + + runtmp.sourmash('prefetch', query_sig, merged_sig, '-o', + 'query-in-merged.csv') + runtmp.sourmash('prefetch', merged_sig, query_sig, '-o', + 'merged-in-query.csv') + + with sourmash_args.FileInputCSV(runtmp.output('query-in-merged.csv')) as r: + query_in_merged = list(r)[0] + + with sourmash_args.FileInputCSV(runtmp.output('merged-in-query.csv')) as r: + merged_in_query = list(r)[0] + + assert query_in_merged['query_containment_ani'] == '1.0' + assert query_in_merged['match_containment_ani'] == '0.9865155060423993' + assert query_in_merged['average_containment_ani'] == '0.9932577530211997' + + assert merged_in_query['match_containment_ani'] == '1.0' + assert merged_in_query['query_containment_ani'] == '0.9865155060423993' + assert merged_in_query['average_containment_ani'] == '0.9932577530211997' diff --git a/tests/test_sbt.py b/tests/test_sbt.py index 9d9ba7273a..a66d0c634e 100644 --- a/tests/test_sbt.py +++ b/tests/test_sbt.py @@ -830,22 +830,22 @@ def test_sbt_gather_threshold_1(): # query with empty hashes assert not new_mh with pytest.raises(ValueError): - tree.gather(SourmashSignature(new_mh)) + tree.best_containment(SourmashSignature(new_mh)) # add one hash new_mh.add_hash(mins.pop()) assert len(new_mh) == 1 - results = tree.gather(SourmashSignature(new_mh)) - assert len(results) == 1 - containment, match_sig, name = results[0] + result = tree.best_containment(SourmashSignature(new_mh)) + assert result + containment, match_sig, name = result assert containment == 1.0 assert match_sig == sig2 assert name is None # check with a threshold -> should be no results. with pytest.raises(ValueError): - tree.gather(SourmashSignature(new_mh), threshold_bp=5000) + tree.best_containment(SourmashSignature(new_mh), threshold_bp=5000) # add three more hashes => length of 4 new_mh.add_hash(mins.pop()) @@ -853,9 +853,9 @@ def test_sbt_gather_threshold_1(): new_mh.add_hash(mins.pop()) assert len(new_mh) == 4 - results = tree.gather(SourmashSignature(new_mh)) - assert len(results) == 1 - containment, match_sig, name = results[0] + result = tree.best_containment(SourmashSignature(new_mh)) + assert result + containment, match_sig, name = result assert containment == 1.0 assert match_sig == sig2 assert name is None @@ -863,7 +863,7 @@ def test_sbt_gather_threshold_1(): # check with a too-high threshold -> should be no results. print('len mh', len(new_mh)) with pytest.raises(ValueError): - tree.gather(SourmashSignature(new_mh), threshold_bp=5000) + tree.best_containment(SourmashSignature(new_mh), threshold_bp=5000) def test_sbt_gather_threshold_5(): @@ -894,17 +894,17 @@ def test_sbt_gather_threshold_5(): new_mh.add_hash(mins.pop()) # should get a result with no threshold (any match at all is returned) - results = tree.gather(SourmashSignature(new_mh)) - assert len(results) == 1 - containment, match_sig, name = results[0] + result = tree.best_containment(SourmashSignature(new_mh)) + assert result + containment, match_sig, name = result assert containment == 1.0 assert match_sig == sig2 assert name is None # now, check with a threshold_bp that should be meet-able. - results = tree.gather(SourmashSignature(new_mh), threshold_bp=5000) - assert len(results) == 1 - containment, match_sig, name = results[0] + results = tree.best_containment(SourmashSignature(new_mh), threshold_bp=5000) + assert result + containment, match_sig, name = result assert containment == 1.0 assert match_sig == sig2 assert name is None @@ -931,10 +931,10 @@ def test_gather_single_return(c): # now, run gather. how many results do we get, and are they in the # right order? - results = tree.gather(sig63) - print(len(results)) - assert len(results) == 1 - assert results[0][0] == 1.0 + result = tree.best_containment(sig63) + print(result) + assert result + assert result.score == 1.0 def test_sbt_jaccard_ordering(runtmp): @@ -1015,10 +1015,10 @@ def test_sbt_protein_command_index(runtmp): do_containment=False, best_only=False) assert len(results) == 2 - results = db2.gather(sig2) - assert results[0][0] == 1.0 - assert results[0][2] == db2._location - assert results[0][2] == db_out + result = db2.best_containment(sig2) + assert result.score == 1.0 + assert result.location == db2._location + assert result.location == db_out @utils.in_tempdir @@ -1049,7 +1049,7 @@ def test_sbt_protein_command_search(c): db_out = utils.get_test_data('prot/protein.sbt.zip') c.run_sourmash('search', sigfile1, db_out, '--threshold', '0.0') - assert '2 matches:' in c.last_result.out + assert '2 matches' in c.last_result.out c.run_sourmash('gather', sigfile1, db_out) assert 'found 1 matches total' in c.last_result.out @@ -1081,12 +1081,12 @@ def test_sbt_hp_command_index(c): # and search, gather results = db2.search(sig1, threshold=0.0, ignore_abundance=True, do_containment=False, best_only=False) - assert len(results) == 2 + assert results - results = db2.gather(sig2) - assert results[0][0] == 1.0 - assert results[0][2] == db2._location - assert results[0][2] == db_out + result = db2.best_containment(sig2) + assert result.score == 1.0 + assert result.location == db2._location + assert result.location == db_out @utils.in_thisdir @@ -1096,7 +1096,7 @@ def test_sbt_hp_command_search(c): db_out = utils.get_test_data('prot/hp.sbt.zip') c.run_sourmash('search', sigfile1, db_out, '--threshold', '0.0') - assert '2 matches:' in c.last_result.out + assert '2 matches' in c.last_result.out c.run_sourmash('gather', sigfile1, db_out, '--threshold', '0.0') assert 'found 1 matches total' in c.last_result.out @@ -1130,10 +1130,10 @@ def test_sbt_dayhoff_command_index(c): do_containment=False, best_only=False) assert len(results) == 2 - results = db2.gather(sig2) - assert results[0][0] == 1.0 - assert results[0][2] == db2._location - assert results[0][2] == db_out + result = db2.best_containment(sig2) + assert result.score == 1.0 + assert result.location == db2._location + assert result.location == db_out @utils.in_thisdir @@ -1143,7 +1143,7 @@ def test_sbt_dayhoff_command_search(c): db_out = utils.get_test_data('prot/dayhoff.sbt.zip') c.run_sourmash('search', sigfile1, db_out, '--threshold', '0.0') - assert '2 matches:' in c.last_result.out + assert '2 matches' in c.last_result.out c.run_sourmash('gather', sigfile1, db_out, '--threshold', '0.0') assert 'found 1 matches total' in c.last_result.out diff --git a/tests/test_search.py b/tests/test_search.py index 0d765e0f96..a1b8171cfd 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1,13 +1,12 @@ "Tests for search.py code." -# CTB TODO: test search protocol with mock class? - import pytest import numpy as np import sourmash_tst_utils as utils from sourmash import search, SourmashSignature, MinHash, load_one_signature -from sourmash.search import (make_jaccard_search_query, make_gather_query, +from sourmash.search import (make_jaccard_search_query, + make_containment_query, SearchResult, PrefetchResult, GatherResult) from sourmash.index import LinearIndex @@ -129,35 +128,35 @@ def test_collect_best_only(): assert search_obj.threshold == 1.0 -def test_make_gather_query(): - # test basic make_gather_query call +def test_make_containment_query(): + # test basic make_containment_query call mh = MinHash(n=0, ksize=31, scaled=1000) for i in range(100): mh.add_hash(i) - search_obj = make_gather_query(mh, 5e4) + search_obj = make_containment_query(mh, 5e4) assert search_obj.score_fn == search_obj.score_containment assert search_obj.require_scaled assert search_obj.threshold == 0.5 -def test_make_gather_query_no_threshold(): - # test basic make_gather_query call +def test_make_containment_query_no_threshold(): + # test basic make_containment_query call mh = MinHash(n=0, ksize=31, scaled=1000) for i in range(100): mh.add_hash(i) - search_obj = make_gather_query(mh, None) + search_obj = make_containment_query(mh, None) assert search_obj.score_fn == search_obj.score_containment assert search_obj.require_scaled assert search_obj.threshold == 0 -def test_make_gather_query_num_minhash(): +def test_make_containment_query_num_minhash(): # will fail on non-scaled minhash mh = MinHash(n=500, ksize=31) @@ -165,12 +164,12 @@ def test_make_gather_query_num_minhash(): mh.add_hash(i) with pytest.raises(TypeError) as exc: - search_obj = make_gather_query(mh, 5e4) + search_obj = make_containment_query(mh, 5e4) assert str(exc.value) == "query signature must be calculated with scaled" -def test_make_gather_query_empty_minhash(): +def test_make_containment_query_empty_minhash(): # will fail on non-scaled minhash mh = MinHash(n=0, ksize=31, scaled=1000) @@ -178,12 +177,12 @@ def test_make_gather_query_empty_minhash(): mh.add_hash(i) with pytest.raises(TypeError) as exc: - search_obj = make_gather_query(mh, -1) + search_obj = make_containment_query(mh, -1) assert str(exc.value) == "threshold_bp must be non-negative" -def test_make_gather_query_high_threshold(): +def test_make_containment_query_high_threshold(): # will fail on non-scaled minhash mh = MinHash(n=0, ksize=31, scaled=1000) @@ -192,7 +191,7 @@ def test_make_gather_query_high_threshold(): # effective threshold > 1; raise ValueError with pytest.raises(ValueError): - search_obj = make_gather_query(mh, 200000) + search_obj = make_containment_query(mh, 200000) class FakeIndex(LinearIndex): @@ -223,8 +222,8 @@ def validate_kwarg_passthru(search_fn, query, args, kwargs): idx.search(query, threshold=0.0, this_kw_arg=5) -def test_index_gather_passthru(): - # check that kwargs are passed through from 'gather' to 'find' +def test_index_containment_passthru(): + # check that kwargs are passed through from 'search' to 'find' query = None def validate_kwarg_passthru(search_fn, query, args, kwargs): @@ -257,6 +256,8 @@ def test_scaledSearchResult(): ss4763_file = utils.get_test_data('47+63.fa.sig') ss47 = load_one_signature(ss47_file, ksize=31, select_moltype='dna') ss4763 = load_one_signature(ss4763_file, ksize=31, select_moltype='dna') + + ss4763 = ss4763.to_mutable() ss4763.filename = ss4763_file scaled = ss47.minhash.scaled @@ -294,6 +295,7 @@ def test_numSearchResult(): ss63_file = utils.get_test_data('num/63.fa.sig') ss47 = load_one_signature(ss47_file, ksize=31, select_moltype='dna') ss63 = load_one_signature(ss63_file, ksize=31, select_moltype='dna') + ss63 = ss63.to_mutable() ss63.filename = ss63_file assert ss47.minhash.num and ss63.minhash.num @@ -371,6 +373,8 @@ def test_PrefetchResult(): ss4763_file = utils.get_test_data('47+63.fa.sig') ss47 = load_one_signature(ss47_file, ksize=31, select_moltype='dna') ss4763 = load_one_signature(ss4763_file, ksize=31, select_moltype='dna') + + ss4763 = ss4763.to_mutable() ss4763.filename = ss4763_file scaled = ss47.minhash.scaled @@ -439,6 +443,8 @@ def test_GatherResult(): ss4763_file = utils.get_test_data('47+63.fa.sig') ss47 = load_one_signature(ss47_file, ksize=31, select_moltype='dna') ss4763 = load_one_signature(ss4763_file, ksize=31, select_moltype='dna') + + ss4763 = ss4763.to_mutable() ss4763.filename = ss4763_file scaled = ss47.minhash.scaled @@ -461,7 +467,7 @@ def test_GatherResult(): res = GatherResult(ss47, ss4763, cmp_scaled=scaled, gather_querymh=remaining_mh, gather_result_rank=gather_result_rank, - total_abund = sum_abunds, + total_weighted_hashes = sum_abunds, orig_query_len=len(ss47.minhash), orig_query_abunds=orig_query_abunds) @@ -482,6 +488,8 @@ def test_GatherResult(): assert res.match_md5 == ss4763.md5sum() assert res.query_n_hashes == len(ss47.minhash) assert res.match_n_hashes == len(ss4763.minhash) + assert res.query_bp == ss47.minhash.unique_dataset_hashes + assert res.match_bp == ss4763.minhash.unique_dataset_hashes assert res.md5 == ss4763.md5sum() assert res.name == ss4763.name assert res.match_filename == ss4763.filename @@ -512,6 +520,8 @@ def test_GatherResult_ci(): ss4763_file = utils.get_test_data('47+63.fa.sig') ss47 = load_one_signature(ss47_file, ksize=31, select_moltype='dna') ss4763 = load_one_signature(ss4763_file, ksize=31, select_moltype='dna') + + ss4763 = ss4763.to_mutable() ss4763.filename = ss4763_file scaled = ss47.minhash.scaled @@ -531,7 +541,7 @@ def test_GatherResult_ci(): res = GatherResult(ss47, ss4763, cmp_scaled=scaled, gather_querymh=remaining_mh, gather_result_rank=gather_result_rank, - total_abund = sum_abunds, + total_weighted_hashes = sum_abunds, orig_query_len=len(ss47.minhash), orig_query_abunds=orig_query_abunds, estimate_ani_ci=True) @@ -568,7 +578,7 @@ def test_GatherResult_incompatible_sigs(): GatherResult(ss47, ss4763, cmp_scaled=1, gather_querymh=ss47.minhash, gather_result_rank=1, - total_abund = 1, + total_weighted_hashes = 1, orig_query_len=len(ss47.minhash), orig_query_abunds=orig_query_abunds) print(str(exc)) @@ -586,7 +596,7 @@ def test_GatherResult_incomplete_input_cmpscaled(): GatherResult(ss47, ss4763, cmp_scaled=None, gather_querymh=ss47.minhash, gather_result_rank=1, - total_abund = 1, + total_weighted_hashes = 1, orig_query_len=len(ss47.minhash), orig_query_abunds=orig_query_abunds) print(str(exc)) @@ -604,7 +614,7 @@ def test_GatherResult_incomplete_input_gathermh(): GatherResult(ss47, ss4763, cmp_scaled=1000, gather_querymh=None, gather_result_rank=1, - total_abund = 1, + total_weighted_hashes = 1, orig_query_len=len(ss47.minhash), orig_query_abunds=orig_query_abunds) print(str(exc)) @@ -622,14 +632,14 @@ def test_GatherResult_incomplete_input_gather_result_rank(): GatherResult(ss47, ss4763, cmp_scaled=1000, gather_querymh=ss47.minhash, gather_result_rank=None, - total_abund = 1, + total_weighted_hashes = 1, orig_query_len=len(ss47.minhash), orig_query_abunds=orig_query_abunds) print(str(exc)) assert "Error: must provide 'gather_result_rank' to GatherResult" in str(exc) -def test_GatherResult_incomplete_input_total_abund(): +def test_GatherResult_incomplete_input_total_weighted_hashes(): ss47_file = utils.get_test_data('47.fa.sig') ss4763_file = utils.get_test_data('47+63.fa.sig') ss47 = load_one_signature(ss47_file, ksize=31, select_moltype='dna') @@ -640,21 +650,21 @@ def test_GatherResult_incomplete_input_total_abund(): GatherResult(ss47, ss4763, cmp_scaled=1000, gather_querymh=ss47.minhash, gather_result_rank=1, - total_abund = None, + total_weighted_hashes = None, orig_query_len=len(ss47.minhash), orig_query_abunds=orig_query_abunds) print(str(exc)) - assert "Error: must provide sum of all abundances ('total_abund') to GatherResult" in str(exc) + assert "Error: must provide sum of all abundances ('total_weighted_hashes') to GatherResult" in str(exc) with pytest.raises(ValueError) as exc: GatherResult(ss47, ss4763, cmp_scaled=1000, gather_querymh=ss47.minhash, gather_result_rank=1, - total_abund = 0, + total_weighted_hashes = 0, orig_query_len=len(ss47.minhash), orig_query_abunds=orig_query_abunds) print(str(exc)) - assert "Error: must provide sum of all abundances ('total_abund') to GatherResult" in str(exc) + assert "Error: must provide sum of all abundances ('total_weighted_hashes') to GatherResult" in str(exc) def test_GatherResult_incomplete_input_orig_query_abunds(): @@ -668,7 +678,7 @@ def test_GatherResult_incomplete_input_orig_query_abunds(): GatherResult(ss47, ss4763, cmp_scaled=1000, gather_querymh=ss47.minhash, gather_result_rank=1, - total_abund = 1, + total_weighted_hashes = 1, orig_query_len=len(ss47.minhash), orig_query_abunds=orig_query_abunds) print(str(exc)) @@ -680,7 +690,7 @@ def test_GatherResult_incomplete_input_orig_query_abunds(): GatherResult(ss47, ss4763, cmp_scaled=1000, gather_querymh=ss47.minhash, gather_result_rank=1, - total_abund = 1, + total_weighted_hashes = 1, orig_query_len=len(ss47.minhash), orig_query_abunds=orig_query_abunds) print(str(exc)) diff --git a/tests/test_signature.py b/tests/test_signature.py index 2df8daf9af..95ea058dc4 100644 --- a/tests/test_signature.py +++ b/tests/test_signature.py @@ -4,9 +4,9 @@ import sourmash from sourmash.signature import SourmashSignature, save_signatures, \ - load_signatures, load_one_signature + load_signatures, load_one_signature, FrozenSourmashSignature import sourmash_tst_utils as utils -from sourmash import MinHash +from sourmash.minhash import MinHash, FrozenMinHash from sourmash_tst_utils import SourmashCommandFailed @@ -139,6 +139,24 @@ def test_roundtrip(track_abundance): assert sig.similarity(sig2) == 1.0 assert sig2.similarity(sig) == 1.0 + assert isinstance(sig, SourmashSignature) + assert not isinstance(sig, FrozenSourmashSignature) + assert isinstance(sig2, FrozenSourmashSignature) + + assert isinstance(e, MinHash) + assert isinstance(sig.minhash, FrozenMinHash) + assert isinstance(sig2.minhash, FrozenMinHash) + + +def test_roundtrip_mutable_frozen(track_abundance): + e = MinHash(n=1, ksize=20, track_abundance=track_abundance) + e.add_kmer("AT" * 10) + sig = SourmashSignature(e) + assert isinstance(sig.minhash, FrozenMinHash) + sig.minhash = sig.minhash.to_mutable() + + sig2 = sig.to_frozen() + assert isinstance(sig2.minhash, FrozenMinHash) def test_load_signature_ksize_nonint(track_abundance): @@ -439,17 +457,7 @@ def test_containment_ANI(): print("\nss1 contained by ss2", s1_cont_s2) print("ss2 contained by ss1", s2_cont_s1) - # first, assess as-is. ANI should be None, bc 2.fa.sig size is inaccurate - assert s1_cont_s2.ani == s2_cont_s1.ani == None - - # since size is inaccurate on 2.fa.sig, need to override to be able to get ani - s1_cont_s2.size_is_inaccurate = False - s2_cont_s1.size_is_inaccurate = False - - print("\nmh1 contained by mh2", s1_cont_s2) - print("mh2 contained by mh1", s2_cont_s1) - - assert (round(s1_cont_s2.ani,3), s1_cont_s2.ani_low, s1_cont_s2.ani_high) == (1.0,1.0,1.0)#(1.0, None, None) + assert (round(s1_cont_s2.ani,3), s1_cont_s2.ani_low, s1_cont_s2.ani_high) == (1.0,1.0,1.0) assert (round(s2_cont_s1.ani,3), round(s2_cont_s1.ani_low,3), round(s2_cont_s1.ani_high,3)) == (0.966, 0.965, 0.967) s1_mc_s2 = ss1.max_containment_ani(ss2, estimate_ci =True) @@ -508,6 +516,8 @@ def test_containment_ANI_downsample(): ss3 = sourmash.load_one_signature(f3, ksize=31) # check that downsampling works properly print(ss2.minhash.scaled) + + ss2 = ss2.to_mutable() ss2.minhash = ss2.minhash.downsample(scaled=2000) assert ss2.minhash.scaled != ss3.minhash.scaled ds_s3c = ss2.containment_ani(ss3, downsample=True) @@ -523,6 +533,7 @@ def test_containment_ANI_downsample(): ss2.max_containment_ani(ss3) assert "ValueError: mismatch in scaled; comparison fail" in e + ss3 = ss3.to_mutable() ss3.minhash = ss3.minhash.downsample(scaled=2000) assert ss2.minhash.scaled == ss3.minhash.scaled ds_s3c_manual = ss2.containment_ani(ss3) @@ -544,14 +555,6 @@ def test_jaccard_ANI(): s1_jani_s2 = ss1.jaccard_ani(ss2) s2_jani_s1 = ss2.jaccard_ani(ss1) - # first, assess as-is. ANI should be 0, bc 2.fa.sig size is inaccurate - assert s1_jani_s2 == s2_jani_s1 - assert (s1_jani_s2.ani, s1_jani_s2.p_nothing_in_common, s1_jani_s2.jaccard_error) == (None, 0.0, 3.891666770716877e-07) - - # since size is inaccurate on 2.fa.sig, need to override to be able to get ani - s1_jani_s2.size_is_inaccurate = False - s2_jani_s1.size_is_inaccurate = False - assert s1_jani_s2 == s2_jani_s1 assert (s1_jani_s2.ani, s1_jani_s2.p_nothing_in_common, s1_jani_s2.jaccard_error) == (0.9783711630110239, 0.0, 3.891666770716877e-07) @@ -595,6 +598,7 @@ def test_jaccard_ANI_downsample(): ss2 = sourmash.load_one_signature(f2) print(ss1.minhash.scaled) + ss1 = ss1.to_mutable() ss1.minhash = ss1.minhash.downsample(scaled=2000) assert ss1.minhash.scaled != ss2.minhash.scaled with pytest.raises(ValueError) as e: @@ -604,7 +608,41 @@ def test_jaccard_ANI_downsample(): ds_s1c = ss1.jaccard_ani(ss2, downsample=True) ds_s2c = ss2.jaccard_ani(ss1, downsample=True) + ss2 = ss2.to_mutable() ss2.minhash = ss2.minhash.downsample(scaled=2000) assert ss1.minhash.scaled == ss2.minhash.scaled ds_j_manual = ss1.jaccard_ani(ss2) assert ds_s1c == ds_s2c == ds_j_manual + + +def test_frozen_signature_update_1(track_abundance): + # setting .name should fail on a FrozenSourmashSignature + e = MinHash(n=1, ksize=20, track_abundance=track_abundance) + e.add_kmer("AT" * 10) + ss = SourmashSignature(e, name='foo').to_frozen() + + with pytest.raises(ValueError): + ss.name = 'foo2' + + +def test_frozen_signature_update_2(track_abundance): + # setting .minhash should fail on a FrozenSourmashSignature + e = MinHash(n=1, ksize=20, track_abundance=track_abundance) + e.add_kmer("AT" * 10) + e2 = e.copy_and_clear() + ss = SourmashSignature(e, name='foo').to_frozen() + + with pytest.raises(ValueError): + ss.minhash = e2 + + +def test_frozen_signature_update_3(track_abundance): + # setting .minhash should succeed with update() context manager + e = MinHash(n=1, ksize=20, track_abundance=track_abundance) + e.add_kmer("AT" * 10) + ss = SourmashSignature(e, name='foo').to_frozen() + + with ss.update() as ss2: + ss2.name = 'foo2' + + assert ss2.name == 'foo2' diff --git a/tests/test_sketchcomparison.py b/tests/test_sketchcomparison.py index 85f9fd485d..30282895fc 100644 --- a/tests/test_sketchcomparison.py +++ b/tests/test_sketchcomparison.py @@ -35,8 +35,8 @@ def test_FracMinHashComparison(track_abundance): assert cmp.cmp_scaled == 1 assert cmp.ksize == 21 assert cmp.moltype == "DNA" - assert cmp.mh1_containment == a.contained_by(b) - assert cmp.mh2_containment == b.contained_by(a) + assert cmp.mh1_containment_in_mh2 == a.contained_by(b) + assert cmp.mh2_containment_in_mh1 == b.contained_by(a) assert cmp.avg_containment == a.avg_containment(b) assert cmp.max_containment == a.max_containment(b) assert cmp.jaccard == a.jaccard(b) == b.jaccard(a) @@ -93,8 +93,8 @@ def test_FracMinHashComparison_downsample(track_abundance): assert cmp.cmp_scaled == cmp_scaled assert cmp.ksize == 21 assert cmp.moltype == "DNA" - assert cmp.mh1_containment == ds_a.contained_by(ds_b) - assert cmp.mh2_containment == ds_b.contained_by(ds_a) + assert cmp.mh1_containment_in_mh2 == ds_a.contained_by(ds_b) + assert cmp.mh2_containment_in_mh1 == ds_b.contained_by(ds_a) assert cmp.avg_containment == ds_a.avg_containment(ds_b) assert cmp.max_containment == ds_a.max_containment(ds_b) assert cmp.jaccard == ds_a.jaccard(ds_b) == ds_b.jaccard(ds_a) @@ -151,8 +151,8 @@ def test_FracMinHashComparison_autodownsample(track_abundance): assert cmp.cmp_scaled == cmp_scaled assert cmp.ksize == 21 assert cmp.moltype == "DNA" - assert cmp.mh1_containment == ds_a.contained_by(ds_b) - assert cmp.mh2_containment == ds_b.contained_by(ds_a) + assert cmp.mh1_containment_in_mh2 == ds_a.contained_by(ds_b) + assert cmp.mh2_containment_in_mh1 == ds_b.contained_by(ds_a) assert cmp.avg_containment == ds_a.avg_containment(ds_b) assert cmp.max_containment == ds_a.max_containment(ds_b) assert cmp.jaccard == ds_a.jaccard(ds_b) == ds_b.jaccard(ds_a) @@ -204,15 +204,17 @@ def test_FracMinHashComparison_ignore_abundance(track_abundance): cmp = FracMinHashComparison(a, b, cmp_scaled = cmp_scaled, ignore_abundance=True) assert cmp.mh1 == a assert cmp.mh2 == b + assert cmp.mh1_cmp == ds_a + assert cmp.mh2_cmp == ds_b assert cmp.ignore_abundance == True assert cmp.cmp_scaled == cmp_scaled assert cmp.ksize == 21 assert cmp.moltype == "DNA" - assert cmp.mh1_containment == a.contained_by(b) - assert cmp.mh2_containment == b.contained_by(a) - assert cmp.avg_containment == b.avg_containment(a) - assert cmp.max_containment == a.max_containment(b) - assert cmp.jaccard == a.jaccard(b) == b.jaccard(a) + assert cmp.mh1_containment_in_mh2 == ds_a.contained_by(ds_b) + assert cmp.mh2_containment_in_mh1 == ds_b.contained_by(ds_a) + assert cmp.avg_containment == ds_b.avg_containment(ds_a) + assert cmp.max_containment == ds_a.max_containment(ds_b) + assert cmp.jaccard == ds_a.jaccard(ds_b) == ds_b.jaccard(ds_a) intersect_mh = ds_a.flatten().intersection(ds_b.flatten()) assert cmp.intersect_mh == intersect_mh == ds_b.flatten().intersection(ds_a.flatten()) assert cmp.total_unique_intersect_hashes == 8 @@ -259,10 +261,10 @@ def test_FracMinHashComparison_fail_threshold(track_abundance): assert cmp.cmp_scaled == cmp_scaled assert cmp.ksize == 21 assert cmp.moltype == "DNA" - assert cmp.mh1_containment == a.contained_by(b) - assert cmp.mh2_containment == b.contained_by(a) - assert cmp.avg_containment == a.avg_containment(b) - assert cmp.max_containment == a.max_containment(b) + assert cmp.mh1_containment_in_mh2 == ds_a.contained_by(ds_b) + assert cmp.mh2_containment_in_mh1 == ds_b.contained_by(ds_a) + assert cmp.avg_containment == ds_a.avg_containment(ds_b) + assert cmp.max_containment == ds_a.max_containment(ds_b) assert cmp.jaccard == a.jaccard(b) == b.jaccard(a) intersect_mh = ds_a.flatten().intersection(ds_b.flatten()) assert cmp.intersect_mh == intersect_mh == ds_b.flatten().intersection(ds_a.flatten()) @@ -291,16 +293,16 @@ def test_FracMinHashComparison_potential_false_negative(): assert cmp.potential_false_negative == False assert cmp.jaccard_ani_untrustworthy == a.jaccard_ani(b).je_exceeds_threshold == b.jaccard_ani(a).je_exceeds_threshold - cmp.estimate_mh1_containment_ani() + cmp.estimate_ani_from_mh1_containment_in_mh2() a_cont_ani_manual = a.containment_ani(b) - assert cmp.mh1_containment_ani == a_cont_ani_manual.ani + assert cmp.ani_from_mh1_containment_in_mh2 == a_cont_ani_manual.ani print(a_cont_ani_manual.p_exceeds_threshold) assert cmp.potential_false_negative == a_cont_ani_manual.p_exceeds_threshold assert cmp.potential_false_negative == False - cmp.estimate_mh2_containment_ani() + cmp.estimate_ani_from_mh2_containment_in_mh1() b_cont_ani_manual = b.containment_ani(a) - assert cmp.mh2_containment_ani == b_cont_ani_manual.ani + assert cmp.ani_from_mh2_containment_in_mh1 == b_cont_ani_manual.ani assert cmp.potential_false_negative == b_cont_ani_manual.p_exceeds_threshold assert cmp.potential_false_negative == False @@ -313,7 +315,7 @@ def test_FracMinHashComparison_potential_false_negative(): #downsample to where it becomes a potential false negative cmp = FracMinHashComparison(a, b, cmp_scaled=16000) - cmp.estimate_mh1_containment_ani() + cmp.estimate_ani_from_mh1_containment_in_mh2() assert cmp.potential_false_negative == True @@ -678,16 +680,16 @@ def test_FracMinHashComparison_ANI(track_abundance): assert cmp.potential_false_negative == a.jaccard_ani(b).p_exceeds_threshold == b.jaccard_ani(a).p_exceeds_threshold assert cmp.jaccard_ani_untrustworthy == a.jaccard_ani(b).je_exceeds_threshold == b.jaccard_ani(a).je_exceeds_threshold - cmp.estimate_mh1_containment_ani() + cmp.estimate_ani_from_mh1_containment_in_mh2() a_cont_ani_manual = a.containment_ani(b) - assert cmp.mh1_containment_ani == a_cont_ani_manual.ani + assert cmp.ani_from_mh1_containment_in_mh2 == a_cont_ani_manual.ani assert cmp.potential_false_negative == a_cont_ani_manual.p_exceeds_threshold # assert cmp.mh1_containment_ani_low is None # assert cmp.mh1_containment_ani_high is None - cmp.estimate_mh2_containment_ani() + cmp.estimate_ani_from_mh2_containment_in_mh1() b_cont_ani_manual = b.containment_ani(a) - assert cmp.mh2_containment_ani == b_cont_ani_manual.ani + assert cmp.ani_from_mh2_containment_in_mh1 == b_cont_ani_manual.ani assert cmp.potential_false_negative == b_cont_ani_manual.p_exceeds_threshold # assert cmp.mh2_containment_ani_low is None # assert cmp.mh2_containment_ani_high is None @@ -725,14 +727,14 @@ def test_FracMinHashComparison_ANI_provide_similarity(track_abundance): b_cont = b.contained_by(a) mc = a.max_containment(b) - cmp.estimate_mh1_containment_ani(containment=a_cont) + cmp.estimate_ani_from_mh1_containment_in_mh2(containment=a_cont) a_cont_ani_manual = a.containment_ani(b) - assert cmp.mh1_containment_ani == a_cont_ani_manual.ani + assert cmp.ani_from_mh1_containment_in_mh2 == a_cont_ani_manual.ani assert cmp.potential_false_negative == a_cont_ani_manual.p_exceeds_threshold - cmp.estimate_mh2_containment_ani(containment=b_cont) + cmp.estimate_ani_from_mh2_containment_in_mh1(containment=b_cont) b_cont_ani_manual = b.containment_ani(a) - assert cmp.mh2_containment_ani == b_cont_ani_manual.ani + assert cmp.ani_from_mh2_containment_in_mh1 == b_cont_ani_manual.ani assert cmp.potential_false_negative == b_cont_ani_manual.p_exceeds_threshold cmp.estimate_max_containment_ani(max_containment=mc) @@ -760,19 +762,19 @@ def test_FracMinHashComparison_ANI_estimate_CI(track_abundance): assert cmp.potential_false_negative == a.jaccard_ani(b).p_exceeds_threshold == b.jaccard_ani(a).p_exceeds_threshold assert cmp.jaccard_ani_untrustworthy == a.jaccard_ani(b).je_exceeds_threshold == b.jaccard_ani(a).je_exceeds_threshold - cmp.estimate_mh1_containment_ani() + cmp.estimate_ani_from_mh1_containment_in_mh2() a_cont_ani_manual = a.containment_ani(b, estimate_ci=True) - assert cmp.mh1_containment_ani == a_cont_ani_manual.ani + assert cmp.ani_from_mh1_containment_in_mh2 == a_cont_ani_manual.ani assert cmp.potential_false_negative == a_cont_ani_manual.p_exceeds_threshold - assert cmp.mh1_containment_ani_low == a_cont_ani_manual.ani_low - assert cmp.mh1_containment_ani_high == a_cont_ani_manual.ani_high + assert cmp.ani_from_mh1_containment_in_mh2_low == a_cont_ani_manual.ani_low + assert cmp.ani_from_mh1_containment_in_mh2_high == a_cont_ani_manual.ani_high - cmp.estimate_mh2_containment_ani() + cmp.estimate_ani_from_mh2_containment_in_mh1() b_cont_ani_manual = b.containment_ani(a, estimate_ci=True) - assert cmp.mh2_containment_ani == b_cont_ani_manual.ani + assert cmp.ani_from_mh2_containment_in_mh1 == b_cont_ani_manual.ani assert cmp.potential_false_negative == b_cont_ani_manual.p_exceeds_threshold - assert cmp.mh2_containment_ani_low == b_cont_ani_manual.ani_low - assert cmp.mh2_containment_ani_high == b_cont_ani_manual.ani_high + assert cmp.ani_from_mh2_containment_in_mh1_low == b_cont_ani_manual.ani_low + assert cmp.ani_from_mh2_containment_in_mh1_high == b_cont_ani_manual.ani_high cmp.estimate_max_containment_ani() mc_ani_manual = a.max_containment_ani(b, estimate_ci=True) @@ -796,19 +798,19 @@ def test_FracMinHashComparison_ANI_estimate_CI_ci99(track_abundance): cmp = FracMinHashComparison(a, b, estimate_ani_ci=True, ani_confidence=0.99) # check containment ani - cmp.estimate_mh1_containment_ani() + cmp.estimate_ani_from_mh1_containment_in_mh2() a_cont_ani_manual = a.containment_ani(b, estimate_ci=True, confidence=0.99) - assert cmp.mh1_containment_ani == a_cont_ani_manual.ani + assert cmp.ani_from_mh1_containment_in_mh2 == a_cont_ani_manual.ani assert cmp.potential_false_negative == a_cont_ani_manual.p_exceeds_threshold - assert cmp.mh1_containment_ani_low == a_cont_ani_manual.ani_low - assert cmp.mh1_containment_ani_high == a_cont_ani_manual.ani_high + assert cmp.ani_from_mh1_containment_in_mh2_low == a_cont_ani_manual.ani_low + assert cmp.ani_from_mh1_containment_in_mh2_high == a_cont_ani_manual.ani_high - cmp.estimate_mh2_containment_ani() + cmp.estimate_ani_from_mh2_containment_in_mh1() b_cont_ani_manual = b.containment_ani(a, estimate_ci=True, confidence=0.99) - assert cmp.mh2_containment_ani == b_cont_ani_manual.ani + assert cmp.ani_from_mh2_containment_in_mh1 == b_cont_ani_manual.ani assert cmp.potential_false_negative == b_cont_ani_manual.p_exceeds_threshold - assert cmp.mh2_containment_ani_low == b_cont_ani_manual.ani_low - assert cmp.mh2_containment_ani_high == b_cont_ani_manual.ani_high + assert cmp.ani_from_mh2_containment_in_mh1_low == b_cont_ani_manual.ani_low + assert cmp.ani_from_mh2_containment_in_mh1_high == b_cont_ani_manual.ani_high cmp.estimate_max_containment_ani() mc_ani_manual = a.max_containment_ani(b, estimate_ci=True, confidence=0.99) @@ -842,19 +844,19 @@ def test_FracMinHashComparison_ANI_downsample(track_abundance): assert cmp.potential_false_negative == a.jaccard_ani(b).p_exceeds_threshold == b.jaccard_ani(a).p_exceeds_threshold assert cmp.jaccard_ani_untrustworthy == a.jaccard_ani(b).je_exceeds_threshold == b.jaccard_ani(a).je_exceeds_threshold - cmp.estimate_mh1_containment_ani() + cmp.estimate_ani_from_mh1_containment_in_mh2() a_cont_ani_manual = a.containment_ani(b, estimate_ci=True) - assert cmp.mh1_containment_ani == a_cont_ani_manual.ani + assert cmp.ani_from_mh1_containment_in_mh2 == a_cont_ani_manual.ani assert cmp.potential_false_negative == a_cont_ani_manual.p_exceeds_threshold - assert cmp.mh1_containment_ani_low == a_cont_ani_manual.ani_low - assert cmp.mh1_containment_ani_high == a_cont_ani_manual.ani_high + assert cmp.ani_from_mh1_containment_in_mh2_low == a_cont_ani_manual.ani_low + assert cmp.ani_from_mh1_containment_in_mh2_high == a_cont_ani_manual.ani_high - cmp.estimate_mh2_containment_ani() + cmp.estimate_ani_from_mh2_containment_in_mh1() b_cont_ani_manual = b.containment_ani(a, estimate_ci=True) - assert cmp.mh2_containment_ani == b_cont_ani_manual.ani + assert cmp.ani_from_mh2_containment_in_mh1 == b_cont_ani_manual.ani assert cmp.potential_false_negative == b_cont_ani_manual.p_exceeds_threshold - assert cmp.mh2_containment_ani_low == b_cont_ani_manual.ani_low - assert cmp.mh2_containment_ani_high == b_cont_ani_manual.ani_high + assert cmp.ani_from_mh2_containment_in_mh1_low == b_cont_ani_manual.ani_low + assert cmp.ani_from_mh2_containment_in_mh1_high == b_cont_ani_manual.ani_high cmp.estimate_max_containment_ani() mc_ani_manual = a.max_containment_ani(b, estimate_ci=True) diff --git a/tests/test_sourmash.py b/tests/test_sourmash.py index 502338f0ee..9d113a3c6e 100644 --- a/tests/test_sourmash.py +++ b/tests/test_sourmash.py @@ -1,7 +1,6 @@ """ Tests for the 'sourmash' command line. """ -import argparse import os import gzip import shutil @@ -10,17 +9,18 @@ import json import csv import pytest -import sys import zipfile import random -from sourmash.search import SearchResult,GatherResult +import numpy import sourmash_tst_utils as utils import sourmash -from sourmash import MinHash +from sourmash import MinHash, sourmash_args from sourmash.sbt import SBT, Node from sourmash.sbtmh import SigLeaf, load_sbt_index +from sourmash.search import SearchResult, GatherResult + try: import matplotlib matplotlib.use('Agg') @@ -33,6 +33,18 @@ from sourmash_tst_utils import SourmashCommandFailed +def test_citation_file(): + import yaml + + thisdir = os.path.dirname(__file__) + citation_file = os.path.join(thisdir, '../CITATION.cff') + + with open(citation_file) as fp: + x = yaml.safe_load(fp) + + assert x['title'] == "sourmash: a library for MinHash sketching of DNA", x + + def test_run_sourmash(): status, out, err = utils.runscript('sourmash', [], fail_ok=True) assert status != 0 # no args provided, ok ;) @@ -128,10 +140,10 @@ def test_load_pathlist_from_file_duplicate(c): assert len(check) == 1 -@utils.in_tempdir -def test_do_serial_compare(c): - # try doing a compare serial - import numpy +def test_compare_serial(runtmp): + # try doing a compare serially + c = runtmp + testsigs = utils.get_test_data('genome-s1*.sig') testsigs = glob.glob(testsigs) @@ -158,10 +170,41 @@ def test_do_serial_compare(c): assert (cmp_out == cmp_calc).all() -@utils.in_tempdir -def test_do_compare_parallel(c): +def test_compare_serial_distance(runtmp): + # try doing a compare serially, with --distance output + c = runtmp + + testsigs = utils.get_test_data('genome-s1*.sig') + testsigs = glob.glob(testsigs) + + c.run_sourmash('compare', '-o', 'cmp', '-k', '21', '--dna', *testsigs, + '--distance') + + cmp_outfile = c.output('cmp') + assert os.path.exists(cmp_outfile) + cmp_out = numpy.load(cmp_outfile) + + sigs = [] + for fn in testsigs: + sigs.append(sourmash.load_one_signature(fn, ksize=21, + select_moltype='dna')) + + cmp_calc = numpy.zeros([len(sigs), len(sigs)]) + for i, si in enumerate(sigs): + for j, sj in enumerate(sigs): + cmp_calc[i][j] = 1 - si.similarity(sj) + + sigs = [] + for fn in testsigs: + sigs.append(sourmash.load_one_signature(fn, ksize=21, + select_moltype='dna')) + assert (cmp_out == cmp_calc).all() + + +def test_compare_parallel(runtmp): # try doing a compare parallel - import numpy + c = runtmp + testsigs = utils.get_test_data('genome-s1*.sig') testsigs = glob.glob(testsigs) @@ -189,10 +232,9 @@ def test_do_compare_parallel(c): assert (cmp_out == cmp_calc).all() -@utils.in_tempdir -def test_do_serial_compare_with_from_file(c): +def test_compare_do_serial_compare_with_from_file(runtmp): # try doing a compare serial - import numpy + c = runtmp testsigs = utils.get_test_data('genome-s1*.sig') testsigs = glob.glob(testsigs) @@ -225,10 +267,10 @@ def test_do_serial_compare_with_from_file(c): assert numpy.array_equal(numpy.sort(cmp_out.flat), numpy.sort(cmp_calc.flat)) -@utils.in_tempdir -def test_do_basic_compare_using_rna_arg(c): +def test_compare_do_basic_compare_using_rna_arg(runtmp): # try doing a basic compare using --rna instead of --dna - import numpy + c = runtmp + testsigs = utils.get_test_data('genome-s1*.sig') testsigs = glob.glob(testsigs) @@ -251,10 +293,9 @@ def test_do_basic_compare_using_rna_arg(c): assert (cmp_out == cmp_calc).all() -def test_do_basic_compare_using_nucleotide_arg(runtmp): +def test_compare_do_basic_using_nucleotide_arg(runtmp): # try doing a basic compare using --nucleotide instead of --dna/--rna - c=runtmp - import numpy + c = runtmp testsigs = utils.get_test_data('genome-s1*.sig') testsigs = glob.glob(testsigs) @@ -277,8 +318,9 @@ def test_do_basic_compare_using_nucleotide_arg(runtmp): assert (cmp_out == cmp_calc).all() -@utils.in_tempdir -def test_do_compare_quiet(c): +def test_compare_quiet(runtmp): + # test 'compare -q' has no output + c = runtmp testdata1 = utils.get_test_data('short.fa') testdata2 = utils.get_test_data('short2.fa') @@ -289,8 +331,19 @@ def test_do_compare_quiet(c): assert not c.last_result.out assert not c.last_result.err -@utils.in_tempdir -def test_do_traverse_directory_compare(c): + +def test_compare_do_traverse_directory_parse_args(runtmp): + # test 'compare' on a directory, using sourmash.cli.parse_args. + import sourmash.commands, sourmash.cli + args = sourmash.cli.parse_args(['compare', '-k', '21', '--dna', + utils.get_test_data('compare')]) + + sourmash.commands.compare(args) + + +def test_compare_do_traverse_directory(runtmp): + # test 'compare' on a directory + c = runtmp c.run_sourmash('compare', '-k 21', '--dna', utils.get_test_data('compare')) print(c.last_result.out) @@ -298,8 +351,9 @@ def test_do_traverse_directory_compare(c): assert 'genome-s11.fa.gz' in c.last_result.out -@utils.in_tempdir -def test_do_traverse_directory_compare_force(c): +def test_compare_do_traverse_directory_compare_force(runtmp): + # test 'compare' on a directory, with -f + c = runtmp sig1 = utils.get_test_data('compare/genome-s10.fa.gz.sig') sig2 = utils.get_test_data('compare/genome-s11.fa.gz.sig') newdir = c.output('newdir') @@ -315,8 +369,9 @@ def test_do_traverse_directory_compare_force(c): assert 'genome-s11.fa.gz' in c.last_result.out -@utils.in_tempdir -def test_do_compare_output_csv(c): +def test_compare_output_csv(runtmp): + # test 'sourmash compare --csv' + c = runtmp testdata1 = utils.get_test_data('short.fa') testdata2 = utils.get_test_data('short2.fa') @@ -340,8 +395,36 @@ def test_do_compare_output_csv(c): next(r) -@utils.in_tempdir -def test_do_compare_downsample(c): +def test_compare_output_csv_gz(runtmp): + # test 'sourmash compare --csv' with a .gz file + c = runtmp + testdata1 = utils.get_test_data('short.fa') + testdata2 = utils.get_test_data('short2.fa') + + c.run_sourmash('sketch', 'dna', '-p', 'k=31,num=500', testdata1, testdata2) + c.run_sourmash('compare', 'short.fa.sig', 'short2.fa.sig', + '--csv', 'xxx.gz') + + with gzip.open(c.output('xxx.gz'), 'rt', newline='') as fp: + r = iter(csv.reader(fp)) + row = next(r) + print(row) + row = next(r) + print(row) + assert float(row[0]) == 1.0 + assert float(row[1]) == 0.93 + row = next(r) + assert float(row[0]) == 0.93 + assert float(row[1]) == 1.0 + + # exactly three lines + with pytest.raises(StopIteration) as e: + next(r) + + +def test_compare_downsample(runtmp): + # test 'compare' with implicit downsampling + c = runtmp testdata1 = utils.get_test_data('short.fa') c.run_sourmash('sketch', 'dna', '-p', 'k=31,scaled=200', testdata1) @@ -359,8 +442,86 @@ def test_do_compare_downsample(c): assert lines[2].startswith('0.6666') -@utils.in_tempdir -def test_do_compare_output_multiple_k(c): +def test_compare_downsample_scaled(runtmp): + # test 'compare' with explicit --scaled downsampling + c = runtmp + testdata1 = utils.get_test_data('short.fa') + c.run_sourmash('sketch', 'dna', '-p', 'k=31,scaled=200', testdata1) + + testdata2 = utils.get_test_data('short2.fa') + c.run_sourmash('sketch', 'dna', '-p', 'k=31,scaled=100', testdata2) + + c.run_sourmash('compare', 'short.fa.sig', 'short2.fa.sig', '--csv', 'xxx', + '--scaled', '300') + + print(c.last_result.status, c.last_result.out, c.last_result.err) + assert 'downsampling to scaled value of 300' in c.last_result.err + with open(c.output('xxx')) as fp: + lines = fp.readlines() + assert len(lines) == 3 + assert lines[1].startswith('1.0,0.0') + assert lines[2].startswith('0.0') + + +def test_compare_downsample_scaled_too_low(runtmp): + # test 'compare' with explicit --scaled downsampling, but lower than min + c = runtmp + testdata1 = utils.get_test_data('short.fa') + c.run_sourmash('sketch', 'dna', '-p', 'k=31,scaled=200', testdata1) + + testdata2 = utils.get_test_data('short2.fa') + c.run_sourmash('sketch', 'dna', '-p', 'k=31,scaled=100', testdata2) + + c.run_sourmash('compare', 'short.fa.sig', 'short2.fa.sig', '--csv', 'xxx', + '--scaled', '100') + + print(c.last_result.status, c.last_result.out, c.last_result.err) + assert 'downsampling to scaled value of 200' in c.last_result.err + assert "WARNING: --scaled specified 100, but max scaled of sketches is 200" in c.last_result.err + with open(c.output('xxx')) as fp: + lines = fp.readlines() + assert len(lines) == 3 + assert lines[1].startswith('1.0,0.6666') + assert lines[2].startswith('0.6666') + + +def test_compare_downsample_scaled_fail_num(runtmp): + # test 'compare' with explicit --scaled downsampling; fail on num sketch + c = runtmp + testdata1 = utils.get_test_data('short.fa') + c.run_sourmash('sketch', 'dna', '-p', 'k=31,num=20', testdata1) + + testdata2 = utils.get_test_data('short2.fa') + c.run_sourmash('sketch', 'dna', '-p', 'k=31,scaled=100', testdata2) + + with pytest.raises(SourmashCommandFailed) as exc: + c.run_sourmash('compare', 'short.fa.sig', 'short2.fa.sig', + '--csv', 'xxx', '--scaled', '300') + + print(c.last_result.status, c.last_result.out, c.last_result.err) + assert "cannot mix scaled signatures with num signatures" in c.last_result.err + + +def test_compare_downsample_scaled_fail_all_num(runtmp): + # test 'compare' with explicit --scaled downsampling; fail on all num sketches + c = runtmp + testdata1 = utils.get_test_data('short.fa') + c.run_sourmash('sketch', 'dna', '-p', 'k=31,num=20', testdata1) + + testdata2 = utils.get_test_data('short2.fa') + c.run_sourmash('sketch', 'dna', '-p', 'k=31,num=30', testdata2) + + with pytest.raises(SourmashCommandFailed) as exc: + c.run_sourmash('compare', 'short.fa.sig', 'short2.fa.sig', + '--csv', 'xxx', '--scaled', '300') + + print(c.last_result.status, c.last_result.out, c.last_result.err) + assert "ERROR: cannot specify --scaled with non-scaled signatures." in c.last_result.err + + +def test_compare_output_multiple_k(runtmp): + # test 'compare' when given multiple k-mer sizes -> should fail + c = runtmp testdata1 = utils.get_test_data('short.fa') testdata2 = utils.get_test_data('short2.fa') c.run_sourmash('sketch', 'translate', '-p', 'k=21,num=500', testdata1) @@ -377,8 +538,10 @@ def test_do_compare_output_multiple_k(c): assert '(saw k-mer sizes 21, 31)' in c.last_result.err -@utils.in_tempdir -def test_do_compare_output_multiple_moltype(c): +def test_compare_output_multiple_moltype(runtmp): + # 'compare' should fail when given multiple moltypes + c = runtmp + testdata1 = utils.get_test_data('short.fa') testdata2 = utils.get_test_data('short2.fa') c.run_sourmash('sketch', 'dna', '-p', 'k=21,num=500', testdata1) @@ -393,8 +556,9 @@ def test_do_compare_output_multiple_moltype(c): assert 'multiple molecule types loaded;' in c.last_result.err -@utils.in_tempdir -def test_do_compare_dayhoff(c): +def test_compare_dayhoff(runtmp): + # test 'compare' works with dayhoff moltype + c = runtmp testdata1 = utils.get_test_data('short.fa') testdata2 = utils.get_test_data('short2.fa') c.run_sourmash('sketch', 'translate', '-p', 'k=21,num=500', '--dayhoff', testdata1) @@ -414,8 +578,9 @@ def test_do_compare_dayhoff(c): assert c.last_result.status == 0 -@utils.in_tempdir -def test_do_compare_hp(c): +def test_compare_hp(runtmp): + # test that 'compare' works with --hp moltype + c = runtmp testdata1 = utils.get_test_data('short.fa') testdata2 = utils.get_test_data('short2.fa') c.run_sourmash('sketch', 'translate', '-p', 'k=21,num=500', '--hp', testdata1) @@ -435,18 +600,11 @@ def test_do_compare_hp(c): assert c.last_result.status == 0 -@utils.in_tempdir -def test_compare_containment(c): - import numpy +def _load_compare_matrix_and_sigs(compare_csv, sigfiles, *, ksize=31): + # load in the output of 'compare' together with sigs - testdata_glob = utils.get_test_data('gather/GCF*.sig') - testdata_sigs = glob.glob(testdata_glob) - - c.run_sourmash('compare', '--containment', '-k', '31', - '--csv', 'output.csv', *testdata_sigs) - - # load the matrix output of compare --containment - with open(c.output('output.csv'), 'rt') as fp: + # load compare CSV + with open(compare_csv, 'rt', newline="") as fp: r = iter(csv.reader(fp)) headers = next(r) @@ -459,10 +617,27 @@ def test_compare_containment(c): # load in all the input signatures idx_to_sig = dict() - for idx, filename in enumerate(testdata_sigs): - ss = sourmash.load_one_signature(filename, ksize=31) + for idx, filename in enumerate(sigfiles): + ss = sourmash.load_one_signature(filename, ksize=ksize) idx_to_sig[idx] = ss + return mat, idx_to_sig + + +def test_compare_containment(runtmp): + # test compare --containment + c = runtmp + + testdata_glob = utils.get_test_data('gather/GCF*.sig') + testdata_sigs = glob.glob(testdata_glob) + + c.run_sourmash('compare', '--containment', '-k', '31', + '--csv', 'output.csv', *testdata_sigs) + + # load the matrix output + mat, idx_to_sig = _load_compare_matrix_and_sigs(c.output('output.csv'), + testdata_sigs) + # check explicit containment against output of compare for i in range(len(idx_to_sig)): ss_i = idx_to_sig[i] @@ -475,33 +650,45 @@ def test_compare_containment(c): assert containment == mat_val, (i, j) -@utils.in_tempdir -def test_compare_max_containment(c): - import numpy +def test_compare_containment_distance(runtmp): + # test compare --containment --distance-matrix + c = runtmp - testdata_glob = utils.get_test_data('scaled/*.sig') + testdata_glob = utils.get_test_data('gather/GCF*.sig') testdata_sigs = glob.glob(testdata_glob) - c.run_sourmash('compare', '--max-containment', '-k', '31', + c.run_sourmash('compare', '--containment', '--distance-matrix', '-k', '31', '--csv', 'output.csv', *testdata_sigs) - # load the matrix output of compare --containment - with open(c.output('output.csv'), 'rt') as fp: - r = iter(csv.reader(fp)) - headers = next(r) + # load the matrix output + mat, idx_to_sig = _load_compare_matrix_and_sigs(c.output('output.csv'), + testdata_sigs) - mat = numpy.zeros((len(headers), len(headers))) - for i, row in enumerate(r): - for j, val in enumerate(row): - mat[i][j] = float(val) + # check explicit containment against output of compare + for i in range(len(idx_to_sig)): + ss_i = idx_to_sig[i] + for j in range(len(idx_to_sig)): + ss_j = idx_to_sig[j] + containment = 1 - ss_j.contained_by(ss_i) + containment = round(containment, 3) + mat_val = round(mat[i][j], 3) - print(mat) + assert containment == mat_val, (i, j) - # load in all the input signatures - idx_to_sig = dict() - for idx, filename in enumerate(testdata_sigs): - ss = sourmash.load_one_signature(filename, ksize=31) - idx_to_sig[idx] = ss + +def test_compare_max_containment(runtmp): + # test compare --max-containment + + c = runtmp + testdata_glob = utils.get_test_data('scaled/*.sig') + testdata_sigs = glob.glob(testdata_glob) + + c.run_sourmash('compare', '--max-containment', '-k', '31', + '--csv', 'output.csv', *testdata_sigs) + + # load the matrix output + mat, idx_to_sig = _load_compare_matrix_and_sigs(c.output('output.csv'), + testdata_sigs) # check explicit containment against output of compare for i in range(len(idx_to_sig)): @@ -515,9 +702,9 @@ def test_compare_max_containment(c): assert containment == mat_val, (i, j) -@utils.in_tempdir -def test_compare_avg_containment(c): - import numpy +def test_compare_avg_containment(runtmp): + # test compare --avg-containment + c = runtmp testdata_glob = utils.get_test_data('scaled/*.sig') testdata_sigs = glob.glob(testdata_glob) @@ -525,23 +712,9 @@ def test_compare_avg_containment(c): c.run_sourmash('compare', '--avg-containment', '-k', '31', '--csv', 'output.csv', *testdata_sigs) - # load the matrix output of compare --containment - with open(c.output('output.csv'), 'rt') as fp: - r = iter(csv.reader(fp)) - headers = next(r) - - mat = numpy.zeros((len(headers), len(headers))) - for i, row in enumerate(r): - for j, val in enumerate(row): - mat[i][j] = float(val) - - print(mat) - - # load in all the input signatures - idx_to_sig = dict() - for idx, filename in enumerate(testdata_sigs): - ss = sourmash.load_one_signature(filename, ksize=31) - idx_to_sig[idx] = ss + # load the matrix output + mat, idx_to_sig = _load_compare_matrix_and_sigs(c.output('output.csv'), + testdata_sigs) # check explicit containment against output of compare for i in range(len(idx_to_sig)): @@ -555,8 +728,10 @@ def test_compare_avg_containment(c): assert containment == mat_val, (i, j) -@utils.in_tempdir -def test_compare_max_containment_and_containment(c): +def test_compare_max_containment_and_containment(runtmp): + # make sure that can't specify both --max-containment and --containment + c = runtmp + testdata_glob = utils.get_test_data('scaled/*.sig') testdata_sigs = glob.glob(testdata_glob) @@ -569,8 +744,10 @@ def test_compare_max_containment_and_containment(c): assert "ERROR: cannot specify more than one containment argument!" in c.last_result.err -@utils.in_tempdir -def test_compare_avg_containment_and_containment(c): +def test_compare_avg_containment_and_containment(runtmp): + # make sure that can't specify both --avg-containment and --containment + c = runtmp + testdata_glob = utils.get_test_data('scaled/*.sig') testdata_sigs = glob.glob(testdata_glob) @@ -583,8 +760,10 @@ def test_compare_avg_containment_and_containment(c): assert "ERROR: cannot specify more than one containment argument!" in c.last_result.err -@utils.in_tempdir -def test_compare_avg_containment_and_max_containment(c): +def test_compare_avg_containment_and_max_containment(runtmp): + # make sure that can't specify both --avg-containment and --max-containment + c = runtmp + testdata_glob = utils.get_test_data('scaled/*.sig') testdata_sigs = glob.glob(testdata_glob) @@ -597,8 +776,10 @@ def test_compare_avg_containment_and_max_containment(c): assert "ERROR: cannot specify more than one containment argument!" in c.last_result.err -@utils.in_tempdir -def test_compare_containment_abund_flatten(c): +def test_compare_containment_abund_flatten_warning(runtmp): + # check warning message about ignoring abund signatures + + c = runtmp s47 = utils.get_test_data('track_abund/47.fa.sig') s63 = utils.get_test_data('track_abund/63.fa.sig') @@ -610,8 +791,10 @@ def test_compare_containment_abund_flatten(c): c.last_result.err -@utils.in_tempdir -def test_compare_ani_abund_flatten(c): +def test_compare_ani_abund_flatten(runtmp): + # check warning message about ignoring abund signatures + + c = runtmp s47 = utils.get_test_data('track_abund/47.fa.sig') s63 = utils.get_test_data('track_abund/63.fa.sig') @@ -623,8 +806,10 @@ def test_compare_ani_abund_flatten(c): c.last_result.err -@utils.in_tempdir -def test_compare_containment_require_scaled(c): +def test_compare_containment_require_scaled(runtmp): + # check warning message about scaled signatures & containment + c = runtmp + s47 = utils.get_test_data('num/47.fa.sig') s63 = utils.get_test_data('num/63.fa.sig') @@ -637,8 +822,10 @@ def test_compare_containment_require_scaled(c): assert c.last_result.status != 0 -@utils.in_tempdir -def test_do_plot_comparison(c): +def test_do_plot_comparison(runtmp): + # make sure 'plot' outputs files ;) + c = runtmp + testdata1 = utils.get_test_data('short.fa') testdata2 = utils.get_test_data('short2.fa') c.run_sourmash('sketch', 'dna', '-p', 'k=31,num=500', testdata1, testdata2) @@ -696,7 +883,6 @@ def test_do_plot_comparison_4_output_dir(c): @utils.in_tempdir def test_do_plot_comparison_5_force(c): - import numpy D = numpy.zeros([2, 2]) D[0, 0] = 5 with open(c.output('cmp'), 'wb') as fp: @@ -712,7 +898,6 @@ def test_do_plot_comparison_5_force(c): @utils.in_tempdir def test_do_plot_comparison_4_fail_not_distance(c): - import numpy D = numpy.zeros([2, 2]) D[0, 0] = 5 with open(c.output('cmp'), 'wb') as fp: @@ -774,8 +959,10 @@ def test_plot_override_labeltext_fail(runtmp): assert '3 labels != matrix size, exiting' in runtmp.last_result.err -@utils.in_tempdir -def test_plot_reordered_labels_csv(c): +def test_plot_reordered_labels_csv(runtmp): + # test 'plot --csv' + c = runtmp + ss2 = utils.get_test_data('2.fa.sig') ss47 = utils.get_test_data('47.fa.sig') ss63 = utils.get_test_data('63.fa.sig') @@ -795,6 +982,29 @@ def test_plot_reordered_labels_csv(c): assert len(akker_vals) == 2 +def test_plot_reordered_labels_csv_gz(runtmp): + # test 'plot --csv' with a .gz output + c = runtmp + + ss2 = utils.get_test_data('2.fa.sig') + ss47 = utils.get_test_data('47.fa.sig') + ss63 = utils.get_test_data('63.fa.sig') + + c.run_sourmash('compare', '-k', '31', '-o', 'cmp', ss2, ss47, ss63) + c.run_sourmash('plot', 'cmp', '--csv', 'neworder.csv.gz') + + with gzip.open(c.output('neworder.csv.gz'), 'rt', newline="") as fp: + r = csv.DictReader(fp) + + akker_vals = set() + for row in r: + akker_vals.add(row['CP001071.1 Akkermansia muciniphila ATCC BAA-835, complete genome']) + + assert '1.0' in akker_vals + assert '0.0' in akker_vals + assert len(akker_vals) == 2 + + def test_plot_subsample_1(runtmp): testdata1 = utils.get_test_data('genome-s10.fa.gz.sig') testdata2 = utils.get_test_data('genome-s11.fa.gz.sig') @@ -1041,16 +1251,18 @@ def test_gather_csv_output_filename_bug(runtmp, linear_gather, prefetch_gather): assert row['filename'] == lca_db_1 -@utils.in_tempdir -def test_compare_no_such_file(c): +def test_compare_no_such_file(runtmp): + # 'compare' fails on nonexistent files + c = runtmp with pytest.raises(SourmashCommandFailed) as e: c.run_sourmash('compare', 'nosuchfile.sig') assert "Error while reading signatures from 'nosuchfile.sig'." in c.last_result.err -@utils.in_tempdir -def test_compare_no_such_file_force(c): +def test_compare_no_such_file_force(runtmp): + # can still run compare on nonexistent with -f + c = runtmp with pytest.raises(SourmashCommandFailed) as e: c.run_sourmash('compare', 'nosuchfile.sig', '-f') @@ -1058,8 +1270,9 @@ def test_compare_no_such_file_force(c): assert "Error while reading signatures from 'nosuchfile.sig'." -@utils.in_tempdir -def test_compare_no_matching_sigs(c): +def test_compare_no_matching_sigs(runtmp): + # compare fails when no sketches found with desired ksize + c = runtmp query = utils.get_test_data('lca/TARA_ASE_MAG_00031.sig') with pytest.raises(SourmashCommandFailed) as exc: @@ -1306,7 +1519,7 @@ def test_do_sourmash_sbt_search_check_bug(runtmp): runtmp.sourmash('search', testdata1, 'zzz') - assert '1 matches:' in runtmp.last_result.out + assert '1 matches' in runtmp.last_result.out tree = load_sbt_index(runtmp.output('zzz.sbt.zip')) assert tree._nodes[0].metadata['min_n_below'] == 431 @@ -1325,7 +1538,7 @@ def test_do_sourmash_sbt_search_empty_sig(runtmp): runtmp.sourmash('search', testdata1, 'zzz') - assert '1 matches:' in runtmp.last_result.out + assert '1 matches' in runtmp.last_result.out tree = load_sbt_index(runtmp.output('zzz.sbt.zip')) assert tree._nodes[0].metadata['min_n_below'] == 1 @@ -1756,7 +1969,7 @@ def test_search_3(runtmp): runtmp.sourmash('search', '-n', '1', 'short.fa.sig', 'short2.fa.sig', 'short3.fa.sig') print(runtmp.last_result.status, runtmp.last_result.out, runtmp.last_result.err) - assert '2 matches; showing first 1' in runtmp.last_result.out + assert '2 matches above threshold 0.080; showing first 1:' in runtmp.last_result.out def test_search_4(runtmp): @@ -1769,11 +1982,21 @@ def test_search_4(runtmp): runtmp.sourmash('search', '-n', '0', 'short.fa.sig', 'short2.fa.sig', 'short3.fa.sig') print(runtmp.last_result.status, runtmp.last_result.out, runtmp.last_result.err) - assert '2 matches:' in runtmp.last_result.out + assert '2 matches above threshold 0.080:' in runtmp.last_result.out assert 'short2.fa' in runtmp.last_result.out assert 'short3.fa' in runtmp.last_result.out +def test_search_5_num_results(runtmp): + query = utils.get_test_data('gather/combined.sig') + against = glob.glob(utils.get_test_data('gather/GCF*.sig')) + + runtmp.sourmash('search', '-n', '5', query, *against) + + print(runtmp.last_result.status, runtmp.last_result.out, runtmp.last_result.err) + assert '12 matches above threshold 0.080; showing first 5:' in runtmp.last_result.out + + def test_index_check_scaled_bounds_negative(runtmp): with pytest.raises(SourmashCommandFailed): runtmp.sourmash('index', 'zzz', 'short.fa.sig', 'short2.fa.sig', '-k', '31', '--scaled', '-5', '--dna') @@ -1824,7 +2047,7 @@ def test_index_metagenome_fromfile(c): print(c.last_result.err) assert ' 33.2% NC_003198.1 Salmonella enterica subsp. enterica serovar T...' in out - assert '12 matches; showing first 3:' in out + assert '12 matches above threshold 0.080; showing first 3:' in out @utils.in_tempdir def test_index_metagenome_fromfile_no_cmdline_sig(c): @@ -1852,8 +2075,8 @@ def test_index_metagenome_fromfile_no_cmdline_sig(c): print(out) print(c.last_result.err) - assert ' 33.2% NC_003198.1 Salmonella enterica subsp. enterica serovar T...' in out - assert '12 matches; showing first 3:' in out + assert ' 33.2% NC_003198.1 Salmonella enterica subsp. enterica serovar T' in out + assert '12 matches above threshold 0.080; showing first 3:' in out def test_search_metagenome(runtmp): @@ -1875,8 +2098,8 @@ def test_search_metagenome(runtmp): print(runtmp.last_result.out) print(runtmp.last_result.err) - assert ' 33.2% NC_003198.1 Salmonella enterica subsp. enterica serovar T...' in runtmp.last_result.out - assert '12 matches; showing first 3:' in runtmp.last_result.out + assert ' 33.2% NC_003198.1 Salmonella enterica subsp. enterica serovar T' in runtmp.last_result.out + assert '12 matches above threshold 0.080; showing first 3:' in runtmp.last_result.out def test_search_metagenome_traverse(runtmp): @@ -1889,8 +2112,8 @@ def test_search_metagenome_traverse(runtmp): print(runtmp.last_result.out) print(runtmp.last_result.err) - assert ' 33.2% NC_003198.1 Salmonella enterica subsp. enterica serovar T...' in runtmp.last_result.out - assert '13 matches; showing first 3:' in runtmp.last_result.out + assert ' 33.2% NC_003198.1 Salmonella enterica subsp. enterica serovar T' in runtmp.last_result.out + assert '13 matches above threshold 0.080; showing first 3:' in runtmp.last_result.out def test_search_metagenome_traverse_check_csv(runtmp): @@ -1917,8 +2140,8 @@ def test_search_metagenome_traverse_check_csv(runtmp): # should have full path to file sig was loaded from assert len(filename) > prefix_len - assert ' 33.2% NC_003198.1 Salmonella enterica subsp. enterica serovar T...' in runtmp.last_result.out - assert '13 matches; showing first 3:' in runtmp.last_result.out + assert ' 33.2% NC_003198.1 Salmonella enterica subsp. enterica serovar T' in runtmp.last_result.out + assert '13 matches above threshold 0.080; showing first 3:' in runtmp.last_result.out @utils.in_thisdir @@ -1990,7 +2213,8 @@ def test_search_check_scaled_bounds_more_than_maximum(runtmp): # explanation: you cannot downsample a scaled SBT to match a scaled # signature, so make sure that when you try such a search, it fails! # (you *can* downsample a signature to match an SBT.) -def test_search_metagenome_downsample(runtmp): +def test_search_metagenome_sbt_downsample_fail(runtmp): + # test downsample on SBT => failure, with --fail-on-empty-databases testdata_glob = utils.get_test_data('gather/GCF*.sig') testdata_sigs = glob.glob(testdata_glob) @@ -2004,18 +2228,41 @@ def test_search_metagenome_downsample(runtmp): assert os.path.exists(runtmp.output('gcf_all.sbt.zip')) - cmd = 'search {} gcf_all -k 21 --scaled 100000'.format(query_sig) - with pytest.raises(SourmashCommandFailed): runtmp.sourmash('search', query_sig, 'gcf_all', '-k', '21', '--scaled', '100000') + print(runtmp.last_result.out) + print(runtmp.last_result.err) + assert runtmp.last_result.status == -1 + assert "ERROR: cannot use 'gcf_all' for this query." in runtmp.last_result.err + assert "search scaled value 100000 is less than database scaled value of 10000" in runtmp.last_result.err + + +def test_search_metagenome_sbt_downsample_nofail(runtmp): + # test downsample on SBT => failure but ok with --no-fail-on-empty-database + testdata_glob = utils.get_test_data('gather/GCF*.sig') + testdata_sigs = glob.glob(testdata_glob) + + query_sig = utils.get_test_data('gather/combined.sig') + + cmd = ['index', 'gcf_all'] + cmd.extend(testdata_sigs) + cmd.extend(['-k', '21']) + + runtmp.sourmash(*cmd) + + assert os.path.exists(runtmp.output('gcf_all.sbt.zip')) + + runtmp.sourmash('search', query_sig, 'gcf_all', '-k', '21', '--scaled', '100000', '--no-fail-on-empty-database') print(runtmp.last_result.out) print(runtmp.last_result.err) + assert runtmp.last_result.status == 0 assert "ERROR: cannot use 'gcf_all' for this query." in runtmp.last_result.err assert "search scaled value 100000 is less than database scaled value of 10000" in runtmp.last_result.err + assert "0 matches" in runtmp.last_result.out def test_search_metagenome_downsample_containment(runtmp): @@ -2037,8 +2284,8 @@ def test_search_metagenome_downsample_containment(runtmp): print(runtmp.last_result.out) print(runtmp.last_result.err) - assert ' 32.9% NC_003198.1 Salmonella enterica subsp. enterica serovar T...' in runtmp.last_result.out - assert '12 matches; showing first 3:' in runtmp.last_result.out + assert ' 32.9% NC_003198.1 Salmonella enterica subsp. enterica serovar T' in runtmp.last_result.out + assert '12 matches above threshold 0.080; showing first 3:' in runtmp.last_result.out @utils.in_tempdir @@ -2061,11 +2308,11 @@ def test_search_metagenome_downsample_index(c): '--containment') print(c) - assert ' 32.9% NC_003198.1 Salmonella enterica subsp. enterica serovar T...' in str( + assert ' 32.9% NC_003198.1 Salmonella enterica subsp. enterica serovar T' in str( c) - assert ' 29.7% NC_003197.2 Salmonella enterica subsp. enterica serovar T...' in str( + assert ' 29.7% NC_003197.2 Salmonella enterica subsp. enterica serovar T' in str( c) - assert '12 matches; showing first 3:' in str(c) + assert '12 matches above threshold 0.080; showing first 3:' in str(c) def test_search_with_picklist(runtmp): @@ -2085,7 +2332,7 @@ def test_search_with_picklist(runtmp): out = runtmp.last_result.out print(out) - assert "3 matches:" in out + assert "3 matches" in out assert "13.1% NC_000853.1 Thermotoga" in out assert "13.0% NC_009486.1 Thermotoga" in out assert "12.8% NC_011978.1 Thermotoga" in out @@ -2107,7 +2354,7 @@ def test_search_with_picklist_exclude(runtmp): out = runtmp.last_result.out print(out) - assert "9 matches; showing first 3:" in out + assert "9 matches above threshold 0.080; showing first 3:" in out assert "33.2% NC_003198.1 Salmonella" in out assert "33.1% NC_003197.2 Salmonella" in out assert "32.2% NC_006905.1 Salmonella" in out @@ -2126,7 +2373,7 @@ def test_search_with_pattern_include(runtmp): out = runtmp.last_result.out print(out) - assert "3 matches:" in out + assert "3 matches" in out assert "13.1% NC_000853.1 Thermotoga" in out assert "13.0% NC_009486.1 Thermotoga" in out assert "12.8% NC_011978.1 Thermotoga" in out @@ -2145,12 +2392,46 @@ def test_search_with_pattern_exclude(runtmp): out = runtmp.last_result.out print(out) - assert "9 matches; showing first 3:" in out + assert "9 matches above threshold 0.080; showing first 3:" in out assert "33.2% NC_003198.1 Salmonella" in out assert "33.1% NC_003197.2 Salmonella" in out assert "32.2% NC_006905.1 Salmonella" in out +def test_search_empty_db_fail(runtmp): + # search should fail on empty db with --fail-on-empty-database + query = utils.get_test_data('2.fa.sig') + against = utils.get_test_data('47.fa.sig') + against2 = utils.get_test_data('lca/47+63.lca.json') + + with pytest.raises(SourmashCommandFailed): + runtmp.sourmash('search', query, against, against2, '-k', '51') + + + err = runtmp.last_result.err + assert "no compatible signatures found in " in err + + +def test_search_empty_db_nofail(runtmp): + # search should not fail on empty db with --no-fail-on-empty-database + query = utils.get_test_data('2.fa.sig') + against = utils.get_test_data('47.fa.sig') + against2 = utils.get_test_data('lca/47+63.lca.json') + + runtmp.sourmash('search', query, against, against2, '-k', '51', + '--no-fail-on-empty-data') + + out = runtmp.last_result.out + err = runtmp.last_result.err + print(out) + print(err) + + assert "no compatible signatures found in " in err + assert "ksize on this database is 31; this is different from requested ksize of 51" in err + assert "loaded 50 total signatures from 2 locations" in err + assert "after selecting signatures compatible with search, 0 remain." in err + + def test_mash_csv_to_sig(runtmp): testdata1 = utils.get_test_data('short.fa.msh.dump') testdata2 = utils.get_test_data('short.fa') @@ -2162,7 +2443,7 @@ def test_mash_csv_to_sig(runtmp): runtmp.sourmash('search', '-k', '31', 'short.fa.sig', 'xxx.sig') print(runtmp.last_result.status, runtmp.last_result.out, runtmp.last_result.err) - assert '1 matches:' in runtmp.last_result.out + assert '1 matches' in runtmp.last_result.out assert '100.0% short.fa' in runtmp.last_result.out @@ -2914,10 +3195,54 @@ def test_gather(runtmp, linear_gather, prefetch_gather): print(runtmp.last_result.out) print(runtmp.last_result.err) - assert '0.9 kbp 100.0% 100.0%' in runtmp.last_result.out + assert '0.9 kbp 100.0% 100.0%' in runtmp.last_result.out + + +def test_gather_csv(runtmp, linear_gather, prefetch_gather): + # test 'gather -o csvfile' + testdata1 = utils.get_test_data('short.fa') + testdata2 = utils.get_test_data('short2.fa') + + runtmp.sourmash('sketch','dna','-p','scaled=10', '--name-from-first', testdata1, testdata2) + + runtmp.sourmash('sketch','dna','-p','scaled=10', '-o', 'query.fa.sig', '--name-from-first', testdata2) + + runtmp.sourmash('index', '-k', '31', 'zzz', 'short.fa.sig', 'short2.fa.sig') + + assert os.path.exists(runtmp.output('zzz.sbt.zip')) + + runtmp.sourmash('gather', 'query.fa.sig', 'zzz', '-o', 'foo.csv', '--threshold-bp=1', linear_gather, prefetch_gather) + + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + csv_file = runtmp.output('foo.csv') + + with open(csv_file) as fp: + reader = csv.DictReader(fp) + row = next(reader) + print(row) + assert float(row['intersect_bp']) == 910 + assert float(row['unique_intersect_bp']) == 910 + assert float(row['remaining_bp']) == 0 + assert float(row['f_orig_query']) == 1.0 + assert float(row['f_unique_to_query']) == 1.0 + assert float(row['f_match']) == 1.0 + assert row['filename'] == 'zzz' + assert row['name'] == 'tr1 4' + assert row['md5'] == 'c9d5a795eeaaf58e286fb299133e1938' + assert row['gather_result_rank'] == '0' + assert row['query_filename'].endswith('short2.fa') + assert row['query_name'] == 'tr1 4' + assert row['query_md5'] == 'c9d5a795' + assert row['query_bp'] == '910' + + assert row['query_abundance'] == 'False' + assert row['n_unique_weighted_found'] == '' -def test_gather_csv(runtmp, linear_gather, prefetch_gather): +def test_gather_csv_gz(runtmp, linear_gather, prefetch_gather): + # test 'gather -o csvfile.gz' testdata1 = utils.get_test_data('short.fa') testdata2 = utils.get_test_data('short2.fa') @@ -2929,14 +3254,14 @@ def test_gather_csv(runtmp, linear_gather, prefetch_gather): assert os.path.exists(runtmp.output('zzz.sbt.zip')) - runtmp.sourmash('gather', 'query.fa.sig', 'zzz', '-o', 'foo.csv', '--threshold-bp=1', linear_gather, prefetch_gather) + runtmp.sourmash('gather', 'query.fa.sig', 'zzz', '-o', 'foo.csv.gz', '--threshold-bp=1', linear_gather, prefetch_gather) print(runtmp.last_result.out) print(runtmp.last_result.err) - csv_file = runtmp.output('foo.csv') + csv_file = runtmp.output('foo.csv.gz') - with open(csv_file) as fp: + with gzip.open(csv_file, "rt", newline="") as fp: reader = csv.DictReader(fp) row = next(reader) print(row) @@ -3045,6 +3370,36 @@ def test_gather_multiple_sbts_save_prefetch_csv(runtmp, linear_gather): assert '870,0.925531914893617,0.9666666666666667' in output +def test_gather_multiple_sbts_save_prefetch_csv_gz(runtmp, linear_gather): + # test --save-prefetch-csv to a .gz file, with multiple databases + testdata1 = utils.get_test_data('short.fa') + testdata2 = utils.get_test_data('short2.fa') + + runtmp.sourmash('sketch','dna', '-p', 'scaled=10', testdata1, testdata2) + + runtmp.sourmash('sketch','dna','-p','scaled=10', '-o', 'query.fa.sig', testdata2) + + runtmp.sourmash('index', 'zzz', 'short.fa.sig', '-k', '31') + + assert os.path.exists(runtmp.output('zzz.sbt.zip')) + + runtmp.sourmash('index', 'zzz2', 'short2.fa.sig', '-k', '31') + + assert os.path.exists(runtmp.output('zzz.sbt.zip')) + + runtmp.sourmash('gather', 'query.fa.sig', 'zzz', 'zzz2', '-o', 'foo.csv', '--save-prefetch-csv', 'prefetch.csv.gz', '--threshold-bp=1', linear_gather) + + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert '0.9 kbp 100.0% 100.0%' in runtmp.last_result.out + assert os.path.exists(runtmp.output('prefetch.csv.gz')) + with gzip.open(runtmp.output('prefetch.csv.gz'), 'rt', newline="") as f: + output = f.read() + print((output,)) + assert '870,0.925531914893617,0.9666666666666667' in output + + def test_gather_multiple_sbts_save_prefetch_and_prefetch_csv(runtmp, linear_gather): # test --save-prefetch-csv with multiple databases testdata1 = utils.get_test_data('short.fa') @@ -3182,18 +3537,57 @@ def approx_equal(a, b, n=5): remaining_mh.remove_many(match.minhash.hashes.keys()) -def test_gather_nomatch(runtmp): +def test_gather_nomatch(runtmp, linear_gather, prefetch_gather): testdata_query = utils.get_test_data( 'gather/GCF_000006945.2_ASM694v2_genomic.fna.gz.sig') testdata_match = utils.get_test_data('lca/TARA_ASE_MAG_00031.sig') - runtmp.sourmash('gather', testdata_query, testdata_match) + out_csv = runtmp.output('results.csv') + + runtmp.sourmash('gather', testdata_query, testdata_match, + '-o', out_csv, + linear_gather, prefetch_gather) print(runtmp.last_result.out) print(runtmp.last_result.err) - assert 'found 0 matches total' in runtmp.last_result.out - assert 'the recovered matches hit 0.0% of the query' in runtmp.last_result.out + assert "No matches found for --threshold-bp at 50.0 kbp." in runtmp.last_result.err + assert not os.path.exists(out_csv) + + +def test_gather_nomatch_create_empty(runtmp, linear_gather, prefetch_gather): + testdata_query = utils.get_test_data( + 'gather/GCF_000006945.2_ASM694v2_genomic.fna.gz.sig') + testdata_match = utils.get_test_data('lca/TARA_ASE_MAG_00031.sig') + + out_csv = runtmp.output('results.csv') + + runtmp.sourmash('gather', testdata_query, testdata_match, + '-o', out_csv, '--create-empty-results', + linear_gather, prefetch_gather) + + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert "No matches found for --threshold-bp at 50.0 kbp." in runtmp.last_result.err + assert os.path.exists(out_csv) + + with open(out_csv, 'rt') as fp: + data = fp.read() + assert not data + + +def test_gather_abund_nomatch(runtmp, linear_gather, prefetch_gather): + testdata_query = utils.get_test_data('gather-abund/reads-s10x10-s11.sig') + testdata_match = utils.get_test_data('gather/GCF_000006945.2_ASM694v2_genomic.fna.gz.sig') + + runtmp.sourmash('gather', testdata_query, testdata_match, + linear_gather, prefetch_gather) + + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert "No matches found for --threshold-bp at 50.0 kbp." in runtmp.last_result.err def test_gather_metagenome(runtmp): @@ -3218,9 +3612,9 @@ def test_gather_metagenome(runtmp): assert 'found 12 matches total' in runtmp.last_result.out assert 'the recovered matches hit 100.0% of the query' in runtmp.last_result.out assert all(('4.9 Mbp 33.2% 100.0%' in runtmp.last_result.out, - 'NC_003198.1 Salmonella enterica subsp...' in runtmp.last_result.out)) + 'NC_003198.1 Salmonella enterica subsp' in runtmp.last_result.out)) assert all(('4.7 Mbp 0.5% 1.5%' in runtmp.last_result.out, - 'NC_011294.1 Salmonella enterica subsp...' in runtmp.last_result.out)) + 'NC_011294.1 Salmonella enterica subs' in runtmp.last_result.out)) @utils.in_tempdir @@ -3252,11 +3646,11 @@ def test_gather_metagenome_num_results(c): assert '(truncated gather because --num-results=10)' in out assert 'the recovered matches hit 99.4% of the query' in out assert all(('4.9 Mbp 33.2% 100.0%' in out, - 'NC_003198.1 Salmonella enterica subsp...' in out)) + 'NC_003198.1 Salmonella enterica subsp' in out)) assert '4.3 Mbp 2.1% 7.3% NC_006511.1 Salmonella enterica subsp' in out -def test_gather_metagenome_threshold_bp(runtmp): +def test_gather_metagenome_threshold_bp(runtmp, linear_gather, prefetch_gather): # set a threshold on the gather output testdata_glob = utils.get_test_data('gather/GCF*.sig') testdata_sigs = glob.glob(testdata_glob) @@ -3271,7 +3665,8 @@ def test_gather_metagenome_threshold_bp(runtmp): assert os.path.exists(runtmp.output('gcf_all.sbt.zip')) - runtmp.sourmash('gather', query_sig, 'gcf_all', '-k', '21', '--threshold-bp', '2e6') + runtmp.sourmash('gather', query_sig, 'gcf_all', '-k', '21', + '--threshold-bp', '2e6', linear_gather, prefetch_gather) print(runtmp.last_result.out) print(runtmp.last_result.err) @@ -3280,7 +3675,59 @@ def test_gather_metagenome_threshold_bp(runtmp): assert 'found less than 2.0 Mbp in common. => exiting' in runtmp.last_result.err assert 'the recovered matches hit 33.2% of the query' in runtmp.last_result.out assert all(('4.9 Mbp 33.2% 100.0%' in runtmp.last_result.out, - 'NC_003198.1 Salmonella enterica subsp...' in runtmp.last_result.out)) + 'NC_003198.1 Salmonella enterica subsp' in runtmp.last_result.out)) + + +def test_gather_metagenome_threshold_bp_low(runtmp, linear_gather, prefetch_gather): + # set a threshold on the gather output => too low + testdata_glob = utils.get_test_data('gather/GCF*.sig') + testdata_sigs = glob.glob(testdata_glob) + + query_sig = utils.get_test_data('gather/combined.sig') + + cmd = ['index', 'gcf_all'] + cmd.extend(testdata_sigs) + cmd.extend(['-k', '21']) + + runtmp.sourmash(*cmd) + + assert os.path.exists(runtmp.output('gcf_all.sbt.zip')) + + runtmp.sourmash('gather', query_sig, 'gcf_all', '-k', '21', + '--threshold-bp', '1', linear_gather, prefetch_gather) + + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert 'found 12 matches total' in runtmp.last_result.out + assert 'found less than 1 bp in common. => exiting' in runtmp.last_result.err + assert 'the recovered matches hit 100.0% of the query' in runtmp.last_result.out + + +def test_gather_metagenome_threshold_bp_too_high(runtmp, linear_gather, prefetch_gather): + # set a threshold on the gather output => no results + testdata_glob = utils.get_test_data('gather/GCF*.sig') + testdata_sigs = glob.glob(testdata_glob) + + query_sig = utils.get_test_data('gather/combined.sig') + + cmd = ['index', 'gcf_all'] + cmd.extend(testdata_sigs) + cmd.extend(['-k', '21']) + + runtmp.sourmash(*cmd) + + assert os.path.exists(runtmp.output('gcf_all.sbt.zip')) + + runtmp.sourmash('gather', query_sig, 'gcf_all', '-k', '21', + '--threshold-bp', '5e6', linear_gather, prefetch_gather) + + out = runtmp.last_result.out + err = runtmp.last_result.err + print(out) + print(err) + + assert "No matches found for --threshold-bp at 5.0 Mbp." in err def test_multigather_metagenome(runtmp): @@ -3305,13 +3752,13 @@ def test_multigather_metagenome(runtmp): assert 'found 12 matches total' in runtmp.last_result.out assert 'the recovered matches hit 100.0% of the query' in runtmp.last_result.out assert all(('4.9 Mbp 33.2% 100.0%' in runtmp.last_result.out, - 'NC_003198.1 Salmonella enterica subsp...' in runtmp.last_result.out)) + 'NC_003198.1 Salmonella enterica subsp' in runtmp.last_result.out)) assert all(('4.7 Mbp 0.5% 1.5%' in runtmp.last_result.out, - 'NC_011294.1 Salmonella enterica subsp...' in runtmp.last_result.out)) + 'NC_011294.1 Salmonella enterica subsp' in runtmp.last_result.out)) -@utils.in_tempdir -def test_multigather_check_scaled_bounds_negative(c): +def test_multigather_check_scaled_bounds_negative(runtmp): + c = runtmp testdata_glob = utils.get_test_data('gather/GCF*.sig') testdata_sigs = glob.glob(testdata_glob) @@ -3330,8 +3777,8 @@ def test_multigather_check_scaled_bounds_negative(c): assert "ERROR: scaled value must be positive" in str(exc.value) -@utils.in_tempdir -def test_multigather_check_scaled_bounds_less_than_minimum(c): +def test_multigather_check_scaled_bounds_less_than_minimum(runtmp): + c = runtmp testdata_glob = utils.get_test_data('gather/GCF*.sig') testdata_sigs = glob.glob(testdata_glob) @@ -3351,8 +3798,8 @@ def test_multigather_check_scaled_bounds_less_than_minimum(c): assert "WARNING: scaled value should be >= 100. Continuing anyway." in str(exc.value) -@utils.in_tempdir -def test_multigather_check_scaled_bounds_more_than_maximum(c): +def test_multigather_check_scaled_bounds_more_than_maximum(runtmp): + c = runtmp testdata_glob = utils.get_test_data('gather/GCF*.sig') testdata_sigs = glob.glob(testdata_glob) @@ -3371,9 +3818,9 @@ def test_multigather_check_scaled_bounds_more_than_maximum(c): assert "WARNING: scaled value should be <= 1e6. Continuing anyway." in c.last_result.err -@utils.in_tempdir -def test_multigather_metagenome_query_from_file(c): +def test_multigather_metagenome_query_from_file(runtmp): # test multigather --query-from-file + c = runtmp testdata_glob = utils.get_test_data('gather/GCF*.sig') testdata_sigs = glob.glob(testdata_glob) @@ -3403,9 +3850,65 @@ def test_multigather_metagenome_query_from_file(c): assert 'found 12 matches total' in out assert 'the recovered matches hit 100.0% of the query' in out assert all(('4.9 Mbp 33.2% 100.0%' in out, - 'NC_003198.1 Salmonella enterica subsp...' in out)) + 'NC_003198.1 Salmonella enterica subsp' in out)) assert all(('4.7 Mbp 0.5% 1.5%' in out, - 'NC_011294.1 Salmonella enterica subsp...' in out)) + 'NC_011294.1 Salmonella enterica subsp' in out)) + + +def test_multigather_metagenome_output(runtmp): + # test multigather CSV output has more than one output line + c = runtmp + testdata_glob = utils.get_test_data('gather/GCF*.sig') + testdata_sigs = glob.glob(testdata_glob) + + query_sig = utils.get_test_data('gather/combined.sig') + + cmd = ['index', 'gcf_all'] + cmd.extend(testdata_sigs) + cmd.extend(['-k', '21']) + c.run_sourmash(*cmd) + + assert os.path.exists(c.output('gcf_all.sbt.zip')) + + cmd = f'multigather --query {query_sig} --db gcf_all -k 21 --threshold-bp=0' + cmd = cmd.split(' ') + c.run_sourmash(*cmd) + + output_csv = runtmp.output('-.csv') + assert os.path.exists(output_csv) + with open(output_csv, newline='') as fp: + x = fp.readlines() + assert len(x) == 13 + + +def test_multigather_metagenome_output_outdir(runtmp): + # test multigather CSV output to different location + c = runtmp + testdata_glob = utils.get_test_data('gather/GCF*.sig') + testdata_sigs = glob.glob(testdata_glob) + + query_sig = utils.get_test_data('gather/combined.sig') + + cmd = ['index', 'gcf_all'] + cmd.extend(testdata_sigs) + cmd.extend(['-k', '21']) + c.run_sourmash(*cmd) + + assert os.path.exists(c.output('gcf_all.sbt.zip')) + + # create output directory + outdir = runtmp.output('savehere') + os.mkdir(outdir) + + cmd = f'multigather --query {query_sig} --db gcf_all -k 21 --threshold-bp=0 --output-dir {outdir}' + cmd = cmd.split(' ') + c.run_sourmash(*cmd) + + output_csv = runtmp.output('savehere/-.csv') + assert os.path.exists(output_csv) + with open(output_csv, newline='') as fp: + x = fp.readlines() + assert len(x) == 13 @utils.in_tempdir @@ -3435,13 +3938,13 @@ def test_multigather_metagenome_query_with_sbt(c): assert 'conducted gather searches on 12 signatures' in err assert 'the recovered matches hit 100.0% of the query' in out assert all(('4.7 Mbp 100.0% 100.0%' in out, - 'NC_011080.1 Salmonella enterica subsp...' in out)) + 'NC_011080.1 Salmonella enterica subsp' in out)) assert all(('4.5 Mbp 100.0% 100.0%' in out, - 'NC_004631.1 Salmonella enterica subsp...' in out)) + 'NC_004631.1 Salmonella enterica subsp' in out)) assert all (('1.6 Mbp 100.0% 100.0%' in out, - 'NC_002163.1 Campylobacter jejuni subs...' in out)) + 'NC_002163.1 Campylobacter jejuni subs' in out)) assert all(('1.9 Mbp 100.0% 100.0%' in out, - 'NC_000853.1 Thermotoga maritima MSB8 ...' in out)) + 'NC_000853.1 Thermotoga maritima MSB8 ' in out)) @utils.in_tempdir @@ -3493,9 +3996,9 @@ def test_multigather_metagenome_query_on_lca_db(c): assert 'conducted gather searches on 2 signatures' in err assert 'the recovered matches hit 100.0% of the query' in out assert all(('5.1 Mbp 100.0% 100.0%' in out, - 'NC_009665.1 Shewanella baltica OS185,...' in out)) + 'NC_009665.1 Shewanella baltica OS185,' in out)) assert all(('5.5 Mbp 100.0% 100.0%' in out, - 'NC_011663.1 Shewanella baltica OS223,...' in out)) + 'NC_011663.1 Shewanella baltica OS223,' in out)) @utils.in_tempdir @@ -3528,17 +4031,17 @@ def test_multigather_metagenome_query_with_sbt_addl_query(c): assert 'the recovered matches hit 100.0% of the query' in out #check for matches to some of the sbt signatures assert all(('4.7 Mbp 100.0% 100.0%' in out, - 'NC_011080.1 Salmonella enterica subsp...' in out)) + 'NC_011080.1 Salmonella enterica subsp' in out)) assert all(('4.5 Mbp 100.0% 100.0%' in out, - 'NC_004631.1 Salmonella enterica subsp...' in out)) + 'NC_004631.1 Salmonella enterica subsp' in out)) assert all (('1.6 Mbp 100.0% 100.0%' in out, - 'NC_002163.1 Campylobacter jejuni subs...' in out)) + 'NC_002163.1 Campylobacter jejuni subs' in out)) assert all(('1.9 Mbp 100.0% 100.0%' in out, - 'NC_000853.1 Thermotoga maritima MSB8 ...' in out)) + 'NC_000853.1 Thermotoga maritima MSB8 ' in out)) #check additional query sig assert all(('4.9 Mbp 100.0% 100.0%' in out, - 'NC_003198.1 Salmonella enterica subsp...' in out)) + 'NC_003198.1 Salmonella enterica subsp' in out)) @utils.in_tempdir @@ -3576,17 +4079,17 @@ def test_multigather_metagenome_sbt_query_from_file_with_addl_query(c): assert 'the recovered matches hit 100.0% of the query' in out #check for matches to some of the sbt signatures assert all(('4.7 Mbp 100.0% 100.0%' in out, - 'NC_011080.1 Salmonella enterica subsp...' in out)) + 'NC_011080.1 Salmonella enterica subsp' in out)) assert all(('4.5 Mbp 100.0% 100.0%' in out, - 'NC_004631.1 Salmonella enterica subsp...' in out)) + 'NC_004631.1 Salmonella enterica subsp' in out)) assert all (('1.6 Mbp 100.0% 100.0%' in out, - 'NC_002163.1 Campylobacter jejuni subs...' in out)) + 'NC_002163.1 Campylobacter jejuni subs' in out)) assert all(('1.9 Mbp 100.0% 100.0%' in out, - 'NC_000853.1 Thermotoga maritima MSB8 ...' in out)) + 'NC_000853.1 Thermotoga maritima MSB8 ' in out)) #check additional query sig assert all(('4.9 Mbp 100.0% 100.0%' in out, - 'NC_003198.1 Salmonella enterica subsp...' in out)) + 'NC_003198.1 Salmonella enterica subsp' in out)) @utils.in_tempdir @@ -3684,12 +4187,12 @@ def test_multigather_metagenome_query_from_file_with_addl_query(c): assert 'found 12 matches total' in out assert 'the recovered matches hit 100.0% of the query' in out assert all(('4.9 Mbp 33.2% 100.0%' in out, - 'NC_003198.1 Salmonella enterica subsp...' in out)) + 'NC_003198.1 Salmonella enterica subsp' in out)) assert all(('4.7 Mbp 0.5% 1.5%' in out, - 'NC_011294.1 Salmonella enterica subsp...' in out)) + 'NC_011294.1 Salmonella enterica subsp' in out)) # second gather query - assert '4.9 Mbp 100.0% 100.0% NC_003198.1 Salmonella enterica subsp...' in out + assert '4.9 Mbp 100.0% 100.0% NC_003198.1 Salmonella enterica subsp' in out assert 'found 1 matches total;' in out assert 'the recovered matches hit 100.0% of the query' in out @@ -3714,9 +4217,9 @@ def test_gather_metagenome_traverse(runtmp, linear_gather, prefetch_gather): assert 'found 12 matches total' in runtmp.last_result.out assert 'the recovered matches hit 100.0% of the query' in runtmp.last_result.out assert all(('4.9 Mbp 33.2% 100.0%' in runtmp.last_result.out, - 'NC_003198.1 Salmonella enterica subsp...' in runtmp.last_result.out)) + 'NC_003198.1 Salmonella enterica subsp' in runtmp.last_result.out)) assert all(('4.7 Mbp 0.5% 1.5%' in runtmp.last_result.out, - 'NC_011294.1 Salmonella enterica subsp...' in runtmp.last_result.out)) + 'NC_011294.1 Salmonella enterica subsp' in runtmp.last_result.out)) def test_gather_metagenome_traverse_check_csv(runtmp, linear_gather, prefetch_gather): @@ -3752,9 +4255,9 @@ def test_gather_metagenome_traverse_check_csv(runtmp, linear_gather, prefetch_ga assert 'found 12 matches total' in runtmp.last_result.out assert 'the recovered matches hit 100.0% of the query' in runtmp.last_result.out assert all(('4.9 Mbp 33.2% 100.0%' in runtmp.last_result.out, - 'NC_003198.1 Salmonella enterica subsp...' in runtmp.last_result.out)) + 'NC_003198.1 Salmonella enterica subsp' in runtmp.last_result.out)) assert all(('4.7 Mbp 0.5% 1.5%' in runtmp.last_result.out, - 'NC_011294.1 Salmonella enterica subsp...' in runtmp.last_result.out)) + 'NC_011294.1 Salmonella enterica subsp' in runtmp.last_result.out)) @utils.in_tempdir @@ -3770,7 +4273,7 @@ def test_gather_traverse_incompatible(c): c.run_sourmash("gather", scaled_sig, c.output('searchme')) print(c.last_result.out) print(c.last_result.err) - assert "5.2 Mbp 100.0% 100.0% NC_009665.1 Shewanella baltica OS185,..." in c.last_result.out + assert "5.2 Mbp 100.0% 100.0% NC_009665.1 Shewanella baltica OS185," in c.last_result.out def test_gather_metagenome_output_unassigned(runtmp): @@ -3787,7 +4290,7 @@ def test_gather_metagenome_output_unassigned(runtmp): assert 'found 1 matches total' in runtmp.last_result.out assert 'the recovered matches hit 33.2% of the query' in runtmp.last_result.out assert all(('4.9 Mbp 33.2% 100.0%' in runtmp.last_result.out, - 'NC_003198.1 Salmonella enterica subsp...' in runtmp.last_result.out)) + 'NC_003198.1 Salmonella enterica subsp' in runtmp.last_result.out)) # now examine unassigned testdata2_glob = utils.get_test_data('gather/GCF_000009505.1*.sig') @@ -3801,6 +4304,36 @@ def test_gather_metagenome_output_unassigned(runtmp): 'NC_011294.1' in runtmp.last_result.out)) +def test_gather_metagenome_output_unassigned_as_zip(runtmp): + testdata_glob = utils.get_test_data('gather/GCF_000195995*g') + testdata_sigs = glob.glob(testdata_glob)[0] + + query_sig = utils.get_test_data('gather/combined.sig') + + runtmp.sourmash('gather', query_sig, testdata_sigs, '-k', '21', '--output-unassigned=unassigned.sig.zip') + + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert 'found 1 matches total' in runtmp.last_result.out + assert 'the recovered matches hit 33.2% of the query' in runtmp.last_result.out + assert all(('4.9 Mbp 33.2% 100.0%' in runtmp.last_result.out, + 'NC_003198.1 Salmonella enterica subsp' in runtmp.last_result.out)) + + assert zipfile.is_zipfile(runtmp.output('unassigned.sig.zip')) + + # now examine unassigned + testdata2_glob = utils.get_test_data('gather/GCF_000009505.1*.sig') + testdata2_sigs = glob.glob(testdata2_glob)[0] + + runtmp.sourmash('gather', 'unassigned.sig.zip', testdata_sigs, testdata2_sigs, '-k', '21') + + print(runtmp.last_result.out) + print(runtmp.last_result.err) + assert all(('1.3 Mbp 13.6% 28.2%' in runtmp.last_result.out, + 'NC_011294.1' in runtmp.last_result.out)) + + def test_gather_metagenome_output_unassigned_none(runtmp): # test what happens when there's nothing unassigned to output testdata_glob = utils.get_test_data('gather/GCF_*.sig') @@ -3816,9 +4349,9 @@ def test_gather_metagenome_output_unassigned_none(runtmp): assert 'found 12 matches total' in runtmp.last_result.out assert 'the recovered matches hit 100.0% of the query' in runtmp.last_result.out assert all(('4.9 Mbp 33.2% 100.0%' in runtmp.last_result.out, - 'NC_003198.1 Salmonella enterica subsp...' in runtmp.last_result.out)) + 'NC_003198.1 Salmonella enterica subsp' in runtmp.last_result.out)) assert all(('4.5 Mbp 0.1% 0.4%' in runtmp.last_result.out, - 'NC_004631.1 Salmonella enterica subsp...' in runtmp.last_result.out)) + 'NC_004631.1 Salmonella enterica subsp' in runtmp.last_result.out)) # now examine unassigned assert not os.path.exists(runtmp.output('unassigned.sig')) @@ -3837,7 +4370,7 @@ def test_gather_metagenome_output_unassigned_nomatches(runtmp, prefetch_gather, prefetch_gather) print(c.last_result.out) - assert 'found 0 matches total;' in c.last_result.out + assert "No matches found for --threshold-bp at 50.0 kbp." in c.last_result.err x = sourmash.load_one_signature(query_sig, ksize=31) y = sourmash.load_one_signature(c.output('foo.sig')) @@ -3857,7 +4390,7 @@ def test_gather_metagenome_output_unassigned_nomatches_protein(runtmp, linear_ga prefetch_gather) print(c.last_result.out) - assert 'found 0 matches total;' in c.last_result.out + assert "No matches found for --threshold-bp at 50.0 kbp." in c.last_result.err c.run_sourmash('sig', 'describe', c.output('foo.sig')) print(c.last_result.out) @@ -3945,7 +4478,11 @@ def test_gather_query_downsample(runtmp, linear_gather, prefetch_gather): print(runtmp.last_result.out) print(runtmp.last_result.err) - assert 'loaded 12 signatures' in runtmp.last_result.err + err = runtmp.last_result.err + + assert 'loaded 36 total signatures from 12 locations.' in err + assert 'after selecting signatures compatible with search, 12 remain.' in err + assert all(('4.9 Mbp 100.0% 100.0%' in runtmp.last_result.out, 'NC_003197.2' in runtmp.last_result.out)) @@ -3964,7 +4501,11 @@ def test_gather_query_downsample_explicit(runtmp, linear_gather, prefetch_gather print(runtmp.last_result.out) print(runtmp.last_result.err) - assert 'loaded 12 signatures' in runtmp.last_result.err + err = runtmp.last_result.err + + assert 'loaded 36 total signatures from 12 locations.' in err + assert 'after selecting signatures compatible with search, 12 remain.' in err + assert all(('4.9 Mbp 100.0% 100.0%' in runtmp.last_result.out, 'NC_003197.2' in runtmp.last_result.out)) @@ -4050,15 +4591,15 @@ def test_gather_with_picklist_exclude(runtmp, linear_gather, prefetch_gather): out = runtmp.last_result.out print(out) assert "found 9 matches total;" in out - assert "4.9 Mbp 33.2% 100.0% NC_003198.1 Salmonella enterica subsp..." in out - assert "1.6 Mbp 10.7% 100.0% NC_002163.1 Campylobacter jejuni subs..." in out - assert "4.8 Mbp 10.4% 31.3% NC_003197.2 Salmonella enterica subsp..." in out - assert "4.7 Mbp 5.2% 16.1% NC_006905.1 Salmonella enterica subsp..." in out - assert "4.7 Mbp 4.0% 12.6% NC_011080.1 Salmonella enterica subsp..." in out - assert "4.6 Mbp 2.9% 9.2% NC_011274.1 Salmonella enterica subsp..." in out - assert "4.3 Mbp 2.1% 7.3% NC_006511.1 Salmonella enterica subsp..." in out - assert "4.7 Mbp 0.5% 1.5% NC_011294.1 Salmonella enterica subsp..." in out - assert "4.5 Mbp 0.1% 0.4% NC_004631.1 Salmonella enterica subsp..." in out + assert "4.9 Mbp 33.2% 100.0% NC_003198.1 Salmonella enterica subsp" in out + assert "1.6 Mbp 10.7% 100.0% NC_002163.1 Campylobacter jejuni subs" in out + assert "4.8 Mbp 10.4% 31.3% NC_003197.2 Salmonella enterica subsp" in out + assert "4.7 Mbp 5.2% 16.1% NC_006905.1 Salmonella enterica subsp" in out + assert "4.7 Mbp 4.0% 12.6% NC_011080.1 Salmonella enterica subsp" in out + assert "4.6 Mbp 2.9% 9.2% NC_011274.1 Salmonella enterica subsp" in out + assert "4.3 Mbp 2.1% 7.3% NC_006511.1 Salmonella enterica subsp" in out + assert "4.7 Mbp 0.5% 1.5% NC_011294.1 Salmonella enterica subsp" in out + assert "4.5 Mbp 0.1% 0.4% NC_004631.1 Salmonella enterica subsp" in out def test_gather_with_pattern_include(runtmp, linear_gather, prefetch_gather): @@ -4096,15 +4637,15 @@ def test_gather_with_pattern_exclude(runtmp, linear_gather, prefetch_gather): out = runtmp.last_result.out print(out) assert "found 9 matches total;" in out - assert "4.9 Mbp 33.2% 100.0% NC_003198.1 Salmonella enterica subsp..." in out - assert "1.6 Mbp 10.7% 100.0% NC_002163.1 Campylobacter jejuni subs..." in out - assert "4.8 Mbp 10.4% 31.3% NC_003197.2 Salmonella enterica subsp..." in out - assert "4.7 Mbp 5.2% 16.1% NC_006905.1 Salmonella enterica subsp..." in out - assert "4.7 Mbp 4.0% 12.6% NC_011080.1 Salmonella enterica subsp..." in out - assert "4.6 Mbp 2.9% 9.2% NC_011274.1 Salmonella enterica subsp..." in out - assert "4.3 Mbp 2.1% 7.3% NC_006511.1 Salmonella enterica subsp..." in out - assert "4.7 Mbp 0.5% 1.5% NC_011294.1 Salmonella enterica subsp..." in out - assert "4.5 Mbp 0.1% 0.4% NC_004631.1 Salmonella enterica subsp..." in out + assert "4.9 Mbp 33.2% 100.0% NC_003198.1 Salmonella enterica subsp" in out + assert "1.6 Mbp 10.7% 100.0% NC_002163.1 Campylobacter jejuni subs" in out + assert "4.8 Mbp 10.4% 31.3% NC_003197.2 Salmonella enterica subsp" in out + assert "4.7 Mbp 5.2% 16.1% NC_006905.1 Salmonella enterica subsp" in out + assert "4.7 Mbp 4.0% 12.6% NC_011080.1 Salmonella enterica subsp" in out + assert "4.6 Mbp 2.9% 9.2% NC_011274.1 Salmonella enterica subsp" in out + assert "4.3 Mbp 2.1% 7.3% NC_006511.1 Salmonella enterica subsp" in out + assert "4.7 Mbp 0.5% 1.5% NC_011294.1 Salmonella enterica subsp" in out + assert "4.5 Mbp 0.1% 0.4% NC_004631.1 Salmonella enterica subsp" in out def test_gather_save_matches(runtmp, linear_gather, prefetch_gather): @@ -4177,7 +4718,6 @@ def test_gather_error_no_sigs_traverse(c): err = c.last_result.err print(err) assert f"Error while reading signatures from '{emptydir}'" in err - assert not 'found 0 matches total;' in err def test_gather_error_no_cardinality_query(runtmp, linear_gather, prefetch_gather): @@ -4222,6 +4762,7 @@ def test_gather_deduce_ksize(runtmp, prefetch_gather, linear_gather): def test_gather_deduce_moltype(runtmp, linear_gather, prefetch_gather): + # gather should automatically figure out ksize testdata1 = utils.get_test_data('short.fa') testdata2 = utils.get_test_data('short2.fa') @@ -4242,6 +4783,7 @@ def test_gather_deduce_moltype(runtmp, linear_gather, prefetch_gather): def test_gather_abund_1_1(runtmp, linear_gather, prefetch_gather): + # check gather with a hand-constructed abundance-weighted query, mark 1 c = runtmp # # make r1.fa with 2x coverage of genome s10 @@ -4283,9 +4825,11 @@ def test_gather_abund_1_1(runtmp, linear_gather, prefetch_gather): assert 'genome-s12.fa.gz' not in out assert "the recovered matches hit 100.0% of the abundance-weighted query" in out + assert "the recovered matches hit 100.0% of the query k-mers (unweighted)" in out def test_gather_abund_10_1(runtmp, prefetch_gather, linear_gather): + # check gather with a hand-constructed abundance-weighted query c = runtmp # see comments in test_gather_abund_1_1, above. # nullgraph/make-reads.py -S 1 -r 200 -C 2 tests/test-data/genome-s10.fa.gz > r1.fa @@ -4332,8 +4876,14 @@ def test_gather_abund_10_1(runtmp, prefetch_gather, linear_gather): average_abunds = [] remaining_bps = [] + n_weighted_list = [] + sum_weighted_list = [] + total_weighted_list = [] + for n, row in enumerate(r): assert int(row['gather_result_rank']) == n + + # other than f_weighted, these are all 'flat' numbers - no abunds. overlap = float(row['intersect_bp']) remaining_bp = float(row['remaining_bp']) unique_overlap = float(row['unique_intersect_bp']) @@ -4346,10 +4896,15 @@ def test_gather_abund_10_1(runtmp, prefetch_gather, linear_gather): average_abunds.append(average_abund) remaining_bps.append(remaining_bp) + # also track weighted calculations + n_weighted_list.append(float(row['n_unique_weighted_found'])) + sum_weighted_list.append(float(row['sum_weighted_found'])) + total_weighted_list.append(float(row['total_weighted_hashes'])) + weighted_calc = [] for (overlap, average_abund) in zip(overlaps, average_abunds): prod = overlap*average_abund - weighted_calc.append(prod) + weighted_calc.append(prod) # @CTB redundant terms with below? total_weighted = sum(weighted_calc) for prod, f_weighted in zip(weighted_calc, f_weighted_list): @@ -4362,8 +4917,31 @@ def test_gather_abund_10_1(runtmp, prefetch_gather, linear_gather): total_query_bp = len(query_mh) * query_mh.scaled assert total_bp_analyzed == total_query_bp + # running sum of n_weighted_list should match sum_weighted_list + sofar_sum = 0 + for i in range(len(n_weighted_list)): + n_weighted = n_weighted_list[i] + sum_weighted = sum_weighted_list[i] + + sofar_sum += n_weighted + assert sum_weighted == sofar_sum + + # weighted list should all be the same, and should match sum_weighted_list + # for this query, since 100% found. + assert min(total_weighted_list) == max(total_weighted_list) + assert min(total_weighted_list) == 7986 + assert sum_weighted_list[-1] == 7986 + + # check/verify calculations for f_weighted - + for i in range(len(n_weighted_list)): + n_weighted = n_weighted_list[i] + f_weighted = f_weighted_list[i] + assert f_weighted == n_weighted / 7986 def test_gather_abund_10_1_ignore_abundance(runtmp, linear_gather, prefetch_gather): + # check gather with an abundance-weighted query, then flattened with + # --ignore-abund + c = runtmp # see comments in test_gather_abund_1_1, above. # nullgraph/make-reads.py -S 1 -r 200 -C 2 tests/test-data/genome-s10.fa.gz > r1.fa @@ -4387,7 +4965,8 @@ def test_gather_abund_10_1_ignore_abundance(runtmp, linear_gather, prefetch_gath print(out) print(err) - assert "the recovered matches hit 100.0% of the query (unweighted)" in out + assert "the recovered matches hit 100.0% of the abundance-weighted query" not in out + assert "the recovered matches hit 100.0% of the query k-mers (unweighted)" in out # when we project s10x10-s11 (r2+r3), 10:1 abundance, # onto s10 and s11 genomes with gather --ignore-abundance, we get: @@ -4408,10 +4987,15 @@ def test_gather_abund_10_1_ignore_abundance(runtmp, linear_gather, prefetch_gath assert row['median_abund'] == '' assert row['std_abund'] == '' + assert row['query_abundance'] == 'False', row['query_abundance'] + assert row['n_unique_weighted_found'] == '' + assert some_results def test_gather_output_unassigned_with_abundance(runtmp, prefetch_gather, linear_gather): + # check --output-unassigned with an abund query + # @CTB: could add check on sum weighted etc. c = runtmp query = utils.get_test_data('gather-abund/reads-s10x10-s11.sig') against = utils.get_test_data('gather-abund/genome-s10.fa.gz.sig') @@ -4438,6 +5022,41 @@ def test_gather_output_unassigned_with_abundance(runtmp, prefetch_gather, linear assert nomatch_mh.hashes[hashval] == abund +def test_gather_empty_db_fail(runtmp, linear_gather, prefetch_gather): + # gather should fail on empty db with --fail-on-empty-database + query = utils.get_test_data('2.fa.sig') + against = utils.get_test_data('47.fa.sig') + against2 = utils.get_test_data('lca/47+63.lca.json') + + with pytest.raises(SourmashCommandFailed): + runtmp.sourmash('gather', query, against, against2, '-k', '51', + linear_gather, prefetch_gather) + + + err = runtmp.last_result.err + assert "no compatible signatures found in " in err + + +def test_gather_empty_db_nofail(runtmp, prefetch_gather, linear_gather): + # gather should not fail on empty db with --no-fail-on-empty-database + query = utils.get_test_data('2.fa.sig') + against = utils.get_test_data('47.fa.sig') + against2 = utils.get_test_data('lca/47+63.lca.json') + + runtmp.sourmash('gather', query, against, against2, '-k', '51', + '--no-fail-on-empty-data', + linear_gather, prefetch_gather) + + out = runtmp.last_result.out + err = runtmp.last_result.err + print(out) + print(err) + + assert "no compatible signatures found in " in err + assert "ksize on this database is 31; this is different from requested ksize of 51" in err + assert "loaded 50 total signatures from 2 locations" in err + assert "after selecting signatures compatible with search, 0 remain." in err + def test_multigather_output_unassigned_with_abundance(runtmp): c = runtmp query = utils.get_test_data('gather-abund/reads-s10x10-s11.sig') @@ -4449,6 +5068,10 @@ def test_multigather_output_unassigned_with_abundance(runtmp): print(c.last_result.out) print(c.last_result.err) + out = c.last_result.out + assert "the recovered matches hit 91.0% of the abundance-weighted query." in out + assert "the recovered matches hit 57.2% of the query k-mers (unweighted)." in out + assert os.path.exists(c.output('r3.fa.unassigned.sig')) nomatch = sourmash.load_one_signature(c.output('r3.fa.unassigned.sig')) @@ -4468,6 +5091,71 @@ def test_multigather_output_unassigned_with_abundance(runtmp): assert nomatch_mh.hashes[hashval] == abund +def test_multigather_empty_db_fail(runtmp): + # multigather should fail on empty db with --fail-on-empty-database + query = utils.get_test_data('2.fa.sig') + against = utils.get_test_data('47.fa.sig') + against2 = utils.get_test_data('lca/47+63.lca.json') + + with pytest.raises(SourmashCommandFailed): + runtmp.sourmash('multigather', '--query', query, + '--db', against, against2, '-k', '51') + + err = runtmp.last_result.err + assert "no compatible signatures found in " in err + + +def test_multigather_empty_db_nofail(runtmp): + # multigather should not fail on empty db with --no-fail-on-empty-database + query = utils.get_test_data('2.fa.sig') + against = utils.get_test_data('47.fa.sig') + against2 = utils.get_test_data('lca/47+63.lca.json') + + runtmp.sourmash('multigather', '--query', query, + '--db', against, against2, '-k', '51', + '--no-fail-on-empty-data') + + out = runtmp.last_result.out + err = runtmp.last_result.err + print(out) + print(err) + + assert "no compatible signatures found in " in err + assert "ksize on this database is 31; this is different from requested ksize of 51" in err + assert "conducted gather searches on 0 signatures" in err + assert "loaded 50 total signatures from 2 locations" in err + assert "after selecting signatures compatible with search, 0 remain." in err + + +def test_multigather_nomatch(runtmp): + testdata_query = utils.get_test_data( + 'gather/GCF_000006945.2_ASM694v2_genomic.fna.gz.sig') + testdata_match = utils.get_test_data('lca/TARA_ASE_MAG_00031.sig') + + runtmp.sourmash('multigather', '--query', testdata_query, + '--db', testdata_match, '-k', '31') + + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert 'found 0 matches total' in runtmp.last_result.out + assert 'the recovered matches hit 0.0% of the query' in runtmp.last_result.out + + +def test_multigather_abund_nomatch(runtmp): + testdata_query = utils.get_test_data('gather-abund/reads-s10x10-s11.sig') + testdata_match = utils.get_test_data('gather/GCF_000006945.2_ASM694v2_genomic.fna.gz.sig') + + runtmp.sourmash('multigather', '--query', testdata_query, + '--db', testdata_match) + + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert 'found 0 matches total' in runtmp.last_result.out + assert 'the recovered matches hit 0.0% of the query' in runtmp.last_result.out + + def test_sbt_categorize(runtmp): testdata1 = utils.get_test_data('genome-s10.fa.gz.sig') testdata2 = utils.get_test_data('genome-s11.fa.gz.sig') @@ -4628,7 +5316,8 @@ def test_sbt_categorize_multiple_ksizes_moltypes(runtmp): def test_watch_check_num_bounds_negative(runtmp): - c=runtmp + # check that watch properly outputs error on negative num + c = runtmp testdata0 = utils.get_test_data('genome-s10.fa.gz') testdata1 = utils.get_test_data('genome-s10.fa.gz.sig') shutil.copyfile(testdata1, c.output('1.sig')) @@ -4642,7 +5331,8 @@ def test_watch_check_num_bounds_negative(runtmp): def test_watch_check_num_bounds_less_than_minimum(runtmp): - c=runtmp + # check that watch properly outputs warnings on small num + c = runtmp testdata0 = utils.get_test_data('genome-s10.fa.gz') testdata1 = utils.get_test_data('genome-s10.fa.gz.sig') shutil.copyfile(testdata1, c.output('1.sig')) @@ -4655,7 +5345,8 @@ def test_watch_check_num_bounds_less_than_minimum(runtmp): def test_watch_check_num_bounds_more_than_maximum(runtmp): - c=runtmp + # check that watch properly outputs warnings on large num + c = runtmp testdata0 = utils.get_test_data('genome-s10.fa.gz') testdata1 = utils.get_test_data('genome-s10.fa.gz.sig') shutil.copyfile(testdata1, c.output('1.sig')) @@ -4667,8 +5358,9 @@ def test_watch_check_num_bounds_more_than_maximum(runtmp): assert "WARNING: num value should be <= 50000. Continuing anyway." in c.last_result.err -@utils.in_tempdir -def test_watch(c): +def test_watch(runtmp): + # check basic watch functionality + c = runtmp testdata0 = utils.get_test_data('genome-s10.fa.gz') testdata1 = utils.get_test_data('genome-s10.fa.gz.sig') shutil.copyfile(testdata1, c.output('1.sig')) @@ -4682,8 +5374,9 @@ def test_watch(c): assert 'FOUND: genome-s10, at 1.000' in c.last_result.out -@utils.in_tempdir -def test_watch_deduce_ksize(c): +def test_watch_deduce_ksize(runtmp): + # check that watch guesses ksize automatically from database + c = runtmp testdata0 = utils.get_test_data('genome-s10.fa.gz') c.run_sourmash('sketch','dna','-p','k=29,num=500', '-o', '1.sig', testdata0) @@ -4698,6 +5391,7 @@ def test_watch_deduce_ksize(c): def test_watch_coverage(runtmp): + # check output details/coverage of found testdata0 = utils.get_test_data('genome-s10.fa.gz') testdata1 = utils.get_test_data('genome-s10.fa.gz.sig') shutil.copyfile(testdata1, runtmp.output('1.sig')) @@ -4719,41 +5413,80 @@ def test_watch_coverage(runtmp): assert 'FOUND: genome-s10, at 1.000' in runtmp.last_result.out -def test_storage_convert(): - import pytest +def test_watch_output_sig(runtmp): + # test watch --output + testdata0 = utils.get_test_data('genome-s10.fa.gz') + testdata1 = utils.get_test_data('genome-s10.fa.gz.sig') + shutil.copyfile(testdata1, runtmp.output('1.sig')) + + args = ['index', '--dna', '-k', '21', 'zzz', '1.sig'] + runtmp.sourmash(*args) + + with open(runtmp.output('query.fa'), 'wt') as fp: + record = list(screed.open(testdata0))[0] + for start in range(0, len(record), 100): + fp.write('>{}\n{}\n'.format(start, + record.sequence[start:start+500])) + + args = ['watch', '--ksize', '21', '--dna', 'zzz', 'query.fa', + '-o', 'out.sig', '--name', 'xyzfoo'] + runtmp.sourmash(*args) + + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + out_sig = runtmp.output('out.sig') + assert os.path.exists(out_sig) + + siglist = list(sourmash.load_file_as_signatures(out_sig)) + assert len(siglist) == 1 + assert siglist[0].filename == 'stdin' + assert siglist[0].name == 'xyzfoo' + + +def test_storage_convert(runtmp): + testdata = utils.get_test_data('v2.sbt.json') + shutil.copyfile(testdata, runtmp.output('v2.sbt.json')) + shutil.copytree(os.path.join(os.path.dirname(testdata), '.sbt.v2'), + runtmp.output('.sbt.v2')) + testsbt = runtmp.output('v2.sbt.json') - with utils.TempDirectory() as location: - testdata = utils.get_test_data('v2.sbt.json') - shutil.copyfile(testdata, os.path.join(location, 'v2.sbt.json')) - shutil.copytree(os.path.join(os.path.dirname(testdata), '.sbt.v2'), - os.path.join(location, '.sbt.v2')) - testsbt = os.path.join(location, 'v2.sbt.json') + original = SBT.load(testsbt, leaf_loader=SigLeaf.load) - original = SBT.load(testsbt, leaf_loader=SigLeaf.load) + args = ['storage', 'convert', '-b', 'ipfs', testsbt] + try: + runtmp.sourmash(*args) + except SourmashCommandFailed: + pass - args = ['storage', 'convert', '-b', 'ipfs', testsbt] - status, out, err = utils.runscript('sourmash', args, - in_directory=location, fail_ok=True) - if not status and "ipfs.exceptions.ConnectionError" in err: + if runtmp.last_result.status: + if "ipfshttpclient.ConnectionError" in runtmp.last_result.err: raise pytest.xfail('ipfs probably not running') + if "No module named 'ipfshttpclient'" in runtmp.last_result.err: + raise pytest.xfail('ipfshttpclient module not installed') + + print("NO FAIL; KEEP ON GOING!") + + + ipfs = SBT.load(testsbt, leaf_loader=SigLeaf.load) + + assert len(original) == len(ipfs) + assert all(n1[1].name == n2[1].name + for (n1, n2) in zip(sorted(original), sorted(ipfs))) - ipfs = SBT.load(testsbt, leaf_loader=SigLeaf.load) + args = ['storage', 'convert', + '-b', """'ZipStorage("{}")'""".format( + runtmp.output('v2.sbt.zip')), + testsbt] + runtmp.sourmash(*args) - assert len(original) == len(ipfs) - assert all(n1[1].name == n2[1].name - for (n1, n2) in zip(sorted(original), sorted(ipfs))) + tar = SBT.load(testsbt, leaf_loader=SigLeaf.load) - args = ['storage', 'convert', - '-b', """'ZipStorage("{}")'""".format( - os.path.join(location, 'v2.sbt.zip')), - testsbt] - status, out, err = utils.runscript('sourmash', args, - in_directory=location) - tar = SBT.load(testsbt, leaf_loader=SigLeaf.load) + assert len(original) == len(tar) + assert all(n1[1].name == n2[1].name + for (n1, n2) in zip(sorted(original), sorted(tar))) - assert len(original) == len(tar) - assert all(n1[1].name == n2[1].name - for (n1, n2) in zip(sorted(original), sorted(tar))) + print("it all worked!!") def test_storage_convert_identity(runtmp): @@ -5009,7 +5742,7 @@ def test_index_matches_search_with_picklist(runtmp): out = runtmp.last_result.out print(out) - assert "3 matches:" in out + assert "3 matches" in out assert "13.1% NC_000853.1 Thermotoga" in out assert "13.0% NC_009486.1 Thermotoga" in out assert "12.8% NC_011978.1 Thermotoga" in out @@ -5050,7 +5783,7 @@ def test_index_matches_search_with_picklist_exclude(runtmp): out = runtmp.last_result.out print(out) - assert "10 matches; showing first 3:" in out + assert "10 matches above threshold 0.080; showing first 3:" in out assert "100.0% -" in out assert "33.2% NC_003198.1 Salmonella" in out assert "33.1% NC_003197.2 Salmonella" in out @@ -5272,8 +6005,7 @@ def test_gather_with_prefetch_picklist_4_manifest_excl(runtmp, linear_gather): print(out) # excluded everything, so nothing to match! - assert "found 0 matches total;" in out - assert "the recovered matches hit 0.0% of the query" in out + assert "No matches found for --threshold-bp at 50.0 kbp." in runtmp.last_result.err def test_gather_with_prefetch_picklist_5_search(runtmp): @@ -5292,7 +6024,7 @@ def test_gather_with_prefetch_picklist_5_search(runtmp): out = runtmp.last_result.out print(out) - assert "12 matches; showing first 3:" in out + assert "12 matches above threshold 0.080; showing first 3:" in out assert " 33.2% NC_003198.1 Salmonella enterica subsp." in out # now, do a gather with the results @@ -5387,8 +6119,8 @@ def test_standalone_manifest_search_fail(runtmp): runtmp.sourmash('search', sig47, mf) -@utils.in_tempdir -def test_search_ani_jaccard(c): +def test_search_ani_jaccard(runtmp): + c = runtmp sig47 = utils.get_test_data('47.fa.sig') sig4763 = utils.get_test_data('47+63.fa.sig') @@ -5413,8 +6145,8 @@ def test_search_ani_jaccard(c): assert row['ani'] == "0.992530907924384" -@utils.in_tempdir -def test_search_ani_jaccard_error_too_high(c): +def test_search_ani_jaccard_error_too_high(runtmp): + c = runtmp testdata1 = utils.get_test_data('short.fa') testdata2 = utils.get_test_data('short2.fa') c.run_sourmash('sketch', 'dna', '-p', 'k=31,scaled=1', testdata1, testdata2) @@ -5441,11 +6173,10 @@ def test_search_ani_jaccard_error_too_high(c): assert row['ani'] == '' assert "WARNING: Jaccard estimation for at least one of these comparisons is likely inaccurate. Could not estimate ANI for these comparisons." in c.last_result.err - assert "WARNING: size estimation for at least one of these sketches may be inaccurate. ANI values will not be reported for these comparisons." in c.last_result.err -@utils.in_tempdir -def test_searchabund_no_ani(c): +def test_searchabund_no_ani(runtmp): + c = runtmp testdata1 = utils.get_test_data('short.fa') testdata2 = utils.get_test_data('short2.fa') c.run_sourmash('sketch', 'dna', '-p', 'k=31,scaled=10,abund', testdata1, testdata2) @@ -5470,8 +6201,8 @@ def test_searchabund_no_ani(c): assert row['ani'] == "" # do we want empty column to appear?? -@utils.in_tempdir -def test_search_ani_containment(c): +def test_search_ani_containment(runtmp): + c = runtmp testdata1 = utils.get_test_data('2+63.fa.sig') testdata2 = utils.get_test_data('47+63.fa.sig') @@ -5513,11 +6244,31 @@ def test_search_ani_containment(c): assert row['ani'] == "0.9868883523107224" -@utils.in_tempdir -def test_search_ani_containment_fail(c): +def test_search_ani_containment_asymmetry(runtmp): + # test contained_by asymmetries, viz #2215 + query_sig = utils.get_test_data('47.fa.sig') + merged_sig = utils.get_test_data('47-63-merge.sig') + + runtmp.sourmash('search', query_sig, merged_sig, '-o', + 'query-in-merged.csv', '--containment') + runtmp.sourmash('search', merged_sig, query_sig, '-o', + 'merged-in-query.csv', '--containment') + + with sourmash_args.FileInputCSV(runtmp.output('query-in-merged.csv')) as r: + query_in_merged = list(r)[0] + + with sourmash_args.FileInputCSV(runtmp.output('merged-in-query.csv')) as r: + merged_in_query = list(r)[0] + + assert query_in_merged['ani'] == '1.0' + assert merged_in_query['ani'] == '0.9865155060423993' + + +def test_search_ani_containment_fail(runtmp): + c = runtmp testdata1 = utils.get_test_data('short.fa') testdata2 = utils.get_test_data('short2.fa') - c.run_sourmash('sketch', 'dna', '-p', 'k=31,scaled=1', testdata1, testdata2) + c.run_sourmash('sketch', 'dna', '-p', 'k=31,scaled=10', testdata1, testdata2) c.run_sourmash('search', '--containment', 'short.fa.sig', 'short2.fa.sig', '-o', 'xxx.csv') print(c.last_result.status, c.last_result.out, c.last_result.err) @@ -5530,14 +6281,16 @@ def test_search_ani_containment_fail(c): row = next(reader) print(row) assert search_result_names == list(row.keys()) - assert float(row['similarity']) == 0.9556701030927836 - assert row['ani'] == "" - - assert "WARNING: size estimation for at least one of these sketches may be inaccurate. ANI values will not be reported for these comparisons." in c.last_result.err + assert round(float(row['similarity']), 3) == 0.967 + assert row['ani'] == "0.998906999319701" + # With PR #2268, this error message should not appear + #assert "WARNING: size estimation for at least one of these sketches may be inaccurate. ANI values will not be reported for these comparisons." in c.last_result.err -@utils.in_tempdir -def test_search_ani_containment_estimate_ci(c): +def test_search_ani_containment_estimate_ci(runtmp): + # test ANI confidence intervals, based on (asymmetric) containment + + c = runtmp testdata1 = utils.get_test_data('2+63.fa.sig') testdata2 = utils.get_test_data('47+63.fa.sig') @@ -5558,8 +6311,8 @@ def test_search_ani_containment_estimate_ci(c): assert row['query_name'] == '' assert row['query_md5'] == '832a45e8' assert row['ani'] == "0.9866751346467802" - assert row['ani_low'] == "0.9861576758035308" - assert row['ani_high'] == "0.9871770716451368" + assert row['ani_low'] == "0.9861576758035308" #"0.9861559138341189" + assert row['ani_high'] == "0.9871770716451368" #"0.9871787293232042" # search other direction c.run_sourmash('search', '--containment', testdata2, testdata1, '-o', 'xxxx.csv', '--estimate-ani-ci') @@ -5578,12 +6331,12 @@ def test_search_ani_containment_estimate_ci(c): assert row['query_name'] == '' assert row['query_md5'] == '491c0a81' assert row['ani'] == "0.9868883523107224" - assert row['ani_low'] == "0.986374049720872" - assert row['ani_high'] == "0.9873870188726516" + assert row['ani_low'] == "0.986374049720872" #"0.9863757952722036" + assert row['ani_high'] == "0.9873870188726516" #"0.9873853776786775" -@utils.in_tempdir -def test_search_ani_max_containment(c): +def test_search_ani_max_containment(runtmp): + c = runtmp testdata1 = utils.get_test_data('2+63.fa.sig') testdata2 = utils.get_test_data('47+63.fa.sig') @@ -5606,8 +6359,10 @@ def test_search_ani_max_containment(c): assert row['ani'] == "0.9868883523107224" -@utils.in_tempdir -def test_search_ani_max_containment_estimate_ci(c): +def test_search_ani_max_containment_estimate_ci(runtmp): + # test ANI confidence intervals, based on (symmetric) max-containment + + c = runtmp testdata1 = utils.get_test_data('2+63.fa.sig') testdata2 = utils.get_test_data('47+63.fa.sig') @@ -5632,8 +6387,9 @@ def test_search_ani_max_containment_estimate_ci(c): assert row['ani_high'] == "0.9873870188726516" -@utils.in_tempdir -def test_search_jaccard_ani_downsample(c): +def test_search_jaccard_ani_downsample(runtmp): + c = runtmp + sig47 = utils.get_test_data('47.fa.sig') sig4763 = utils.get_test_data('47+63.fa.sig') ss47 = sourmash.load_one_signature(sig47) @@ -5673,16 +6429,15 @@ def test_search_jaccard_ani_downsample(c): row = next(reader) print(row) assert round(float(row['similarity']), 3) == round(0.6634517766497462, 3) - #downsampled is too small, so ANI is 0. can get from dist, though - assert row['ani'] == "" + assert round(float(row['ani']), 3) == 0.993 #downsample manually and assert same ANI ss47_ds = signature.load_one_signature(ds_sig47) print("SCALED:", ss47_ds.minhash.scaled, ss4763.minhash.scaled) ani_info = ss47_ds.jaccard_ani(ss4763, downsample=True) print(ani_info) - assert ani_info.ani == None - assert (1 - round(ani_info.dist, 3)) == round(0.992530907924384, 3) + assert round(ani_info.ani,3) == 0.993 + assert (1 - round(ani_info.dist, 3)) == 0.993 def test_gather_ani_csv(runtmp, linear_gather, prefetch_gather): @@ -5766,20 +6521,20 @@ def test_gather_ani_csv_estimate_ci(runtmp, linear_gather, prefetch_gather): assert row['query_name'] == 'tr1 4' assert row['query_md5'] == 'c9d5a795' assert row['query_bp'] == '910' - assert row['query_containment_ani']== '' - assert row['query_containment_ani_low']== '' - assert row['query_containment_ani_high']== '' - assert row['match_containment_ani'] == '' - assert row['match_containment_ani_low'] == '' - assert row['match_containment_ani_high'] == '' - assert row['average_containment_ani'] == '' - assert row['max_containment_ani'] =='' + assert row['query_containment_ani'] == '1.0' + assert row['query_containment_ani_low'] == '1.0' + assert row['query_containment_ani_high'] == '1.0' + assert row['match_containment_ani'] == '1.0' + assert row['match_containment_ani_low'] == '1.0' + assert row['match_containment_ani_high'] == '1.0' + assert row['average_containment_ani'] == '1.0' + assert row['max_containment_ani'] == '1.0' assert row['potential_false_negative'] == 'False' -@utils.in_tempdir -def test_compare_containment_ani(c): - import numpy +def test_compare_containment_ani(runtmp): + # test compare --containment --ani + c = runtmp sigfiles = ["2.fa.sig", "2+63.fa.sig", "47.fa.sig", "63.fa.sig"] testdata_sigs = [utils.get_test_data(c) for c in sigfiles] @@ -5787,23 +6542,9 @@ def test_compare_containment_ani(c): c.run_sourmash('compare', '--containment', '-k', '31', '--ani', '--csv', 'output.csv', *testdata_sigs) - # load the matrix output of compare --containment --estimate-ani - with open(c.output('output.csv'), 'rt') as fp: - r = iter(csv.reader(fp)) - headers = next(r) - - mat = numpy.zeros((len(headers), len(headers))) - for i, row in enumerate(r): - for j, val in enumerate(row): - mat[i][j] = float(val) - - print(mat) - - # load in all the input signatures - idx_to_sig = dict() - for idx, filename in enumerate(testdata_sigs): - ss = sourmash.load_one_signature(filename, ksize=31) - idx_to_sig[idx] = ss + # load the matrix output + mat, idx_to_sig = _load_compare_matrix_and_sigs(c.output('output.csv'), + testdata_sigs) # check explicit containment against output of compare for i in range(len(idx_to_sig)): @@ -5827,20 +6568,21 @@ def test_compare_containment_ani(c): print(c.last_result.err) print(c.last_result.out) assert "WARNING: Some of these sketches may have no hashes in common based on chance alone (false negatives). Consider decreasing your scaled value to prevent this." in c.last_result.err - assert "WARNING: size estimation for at least one of these sketches may be inaccurate. ANI values will be set to 0 for these comparisons." in c.last_result.err -@utils.in_tempdir -def test_compare_jaccard_ani(c): +def test_compare_containment_ani_asymmetry(runtmp): + # very specifically test asymmetry of ANI in containment matrices ;) + c = runtmp + import numpy - sigfiles = ["2.fa.sig", "2+63.fa.sig", "47.fa.sig", "63.fa.sig"] + sigfiles = ["47.fa.sig", "47-63-merge.sig"] testdata_sigs = [utils.get_test_data(c) for c in sigfiles] - c.run_sourmash('compare', '-k', '31', '--estimate-ani', - '--csv', 'output.csv', *testdata_sigs) + c.run_sourmash('compare', '--containment', '-k', '31', + '--ani', '--csv', 'output.csv', *testdata_sigs) - # load the matrix output of compare --estimate-ani + # load the matrix output of compare --containment --estimate-ani with open(c.output('output.csv'), 'rt') as fp: r = iter(csv.reader(fp)) headers = next(r) @@ -5859,6 +6601,130 @@ def test_compare_jaccard_ani(c): idx_to_sig[idx] = ss # check explicit containment against output of compare + for i in range(len(idx_to_sig)): + ss_i = idx_to_sig[i] + for j in range(len(idx_to_sig)): + mat_val = round(mat[i][j], 6) + print(mat_val) + if i == j: + assert 1 == mat_val + else: + ss_j = idx_to_sig[j] + containment_ani = ss_j.containment_ani(ss_i).ani + if containment_ani is not None: + containment_ani = round(containment_ani, 6) + else: + containment_ani = 0.0 + mat_val = round(mat[i][j], 6) + + assert containment_ani == mat_val #, (i, j) + + print(c.last_result.err) + print(c.last_result.out) + + +def test_compare_jaccard_ani(runtmp): + c = runtmp + + sigfiles = ["47.fa.sig", "47-63-merge.sig"] + testdata_sigs = [utils.get_test_data(c) for c in sigfiles] + + c.run_sourmash('compare', '--containment', '-k', '31', + '--ani', '--csv', 'output.csv', *testdata_sigs) + + # load the matrix output + mat, idx_to_sig = _load_compare_matrix_and_sigs(c.output('output.csv'), + testdata_sigs) + + # check explicit containment against output of compare + for i in range(len(idx_to_sig)): + ss_i = idx_to_sig[i] + for j in range(len(idx_to_sig)): + mat_val = round(mat[i][j], 6) + print(mat_val) + if i == j: + assert 1 == mat_val + else: + ss_j = idx_to_sig[j] + containment_ani = ss_j.containment_ani(ss_i).ani + if containment_ani is not None: + containment_ani = round(containment_ani, 6) + else: + containment_ani = 0.0 + mat_val = round(mat[i][j], 6) + + assert containment_ani == mat_val #, (i, j) + + print(c.last_result.err) + print(c.last_result.out) + + +def test_compare_jaccard_protein_parallel_ani_bug(runtmp): + # this checks a bug that occurred with serialization of protein minhash + # in parallel situations. See #2262. + c = runtmp + + sigfile = utils.get_test_data("prot/protein.zip") + + c.run_sourmash('compare', '--ani', '-p', '2', '--csv', 'output.csv', + sigfile) + + print(c.last_result.err) + print(c.last_result.out) + + +def test_compare_containment_ani_asymmetry_distance(runtmp): + # very specifically test asymmetry of ANI in containment matrices ;) + # ...calculated with --distance + c = runtmp + + sigfiles = ["47.fa.sig", "47-63-merge.sig"] + testdata_sigs = [utils.get_test_data(c) for c in sigfiles] + + c.run_sourmash('compare', '--containment', '-k', '31', '--distance-matrix', + '--ani', '--csv', 'output.csv', *testdata_sigs) + + # load the matrix output + mat, idx_to_sig = _load_compare_matrix_and_sigs(c.output('output.csv'), + testdata_sigs) + + # check explicit containment against output of compare + for i in range(len(idx_to_sig)): + ss_i = idx_to_sig[i] + for j in range(len(idx_to_sig)): + mat_val = round(mat[i][j], 6) + print(mat_val) + if i == j: + assert 0 == mat_val + else: + ss_j = idx_to_sig[j] + containment_ani = 1 - ss_j.containment_ani(ss_i).ani + if containment_ani is not None: + containment_ani = round(containment_ani, 6) + else: + containment_ani = 1 + mat_val = round(mat[i][j], 6) + + assert containment_ani == mat_val #, (i, j) + + print(c.last_result.err) + print(c.last_result.out) + + +def test_compare_jaccard_ani(runtmp): + c = runtmp + + sigfiles = ["2.fa.sig", "2+63.fa.sig", "47.fa.sig", "63.fa.sig"] + testdata_sigs = [utils.get_test_data(c) for c in sigfiles] + + c.run_sourmash('compare', '-k', '31', '--estimate-ani', + '--csv', 'output.csv', *testdata_sigs) + + # load the matrix output + mat, idx_to_sig = _load_compare_matrix_and_sigs(c.output('output.csv'), + testdata_sigs) + + # check explicit calculations against output of compare for i in range(len(idx_to_sig)): ss_i = idx_to_sig[i] for j in range(len(idx_to_sig)): @@ -5880,12 +6746,11 @@ def test_compare_jaccard_ani(c): print(c.last_result.err) print(c.last_result.out) assert "WARNING: Some of these sketches may have no hashes in common based on chance alone (false negatives). Consider decreasing your scaled value to prevent this." in c.last_result.err - assert "WARNING: size estimation for at least one of these sketches may be inaccurate. ANI values will be set to 0 for these comparisons." in c.last_result.err -@utils.in_tempdir -def test_compare_jaccard_ani_jaccard_error_too_high(c): - import numpy +def test_compare_jaccard_ani_jaccard_error_too_high(runtmp): + c = runtmp + testdata1 = utils.get_test_data('short.fa') sig1 = c.output('short.fa.sig') testdata2 = utils.get_test_data('short2.fa') @@ -5897,24 +6762,9 @@ def test_compare_jaccard_ani_jaccard_error_too_high(c): c.run_sourmash('compare', '-k', '31', '--estimate-ani', '--csv', 'output.csv', 'short.fa.sig', 'short2.fa.sig') print(c.last_result.status, c.last_result.out, c.last_result.err) - - # load the matrix output of compare --estimate-ani - with open(c.output('output.csv'), 'rt') as fp: - r = iter(csv.reader(fp)) - headers = next(r) - - mat = numpy.zeros((len(headers), len(headers))) - for i, row in enumerate(r): - for j, val in enumerate(row): - mat[i][j] = float(val) - - print(mat) - - # load in all the input signatures - idx_to_sig = dict() - for idx, filename in enumerate(testdata_sigs): - ss = sourmash.load_one_signature(filename, ksize=31) - idx_to_sig[idx] = ss + # load the matrix output + mat, idx_to_sig = _load_compare_matrix_and_sigs(c.output('output.csv'), + testdata_sigs) # check explicit containment against output of compare for i in range(len(idx_to_sig)): @@ -5937,36 +6787,20 @@ def test_compare_jaccard_ani_jaccard_error_too_high(c): assert "WARNING: Jaccard estimation for at least one of these comparisons is likely inaccurate. Could not estimate ANI for these comparisons." in c.last_result.err - assert "WARNING: size estimation for at least one of these sketches may be inaccurate. ANI values will be set to 0 for these comparisons." in c.last_result.err -@utils.in_tempdir -def test_compare_max_containment_ani(c): - import numpy - +def test_compare_max_containment_ani(runtmp): + c = runtmp + sigfiles = ["2.fa.sig", "2+63.fa.sig", "47.fa.sig", "63.fa.sig"] testdata_sigs = [utils.get_test_data(c) for c in sigfiles] c.run_sourmash('compare', '--max-containment', '-k', '31', '--estimate-ani', '--csv', 'output.csv', *testdata_sigs) - # load the matrix output of compare --max-containment --estimate-ani - with open(c.output('output.csv'), 'rt') as fp: - r = iter(csv.reader(fp)) - headers = next(r) - - mat = numpy.zeros((len(headers), len(headers))) - for i, row in enumerate(r): - for j, val in enumerate(row): - mat[i][j] = float(val) - - print(mat) - - # load in all the input signatures - idx_to_sig = dict() - for idx, filename in enumerate(testdata_sigs): - ss = sourmash.load_one_signature(filename, ksize=31) - idx_to_sig[idx] = ss + # load the matrix output + mat, idx_to_sig = _load_compare_matrix_and_sigs(c.output('output.csv'), + testdata_sigs) # check explicit containment against output of compare for i in range(len(idx_to_sig)): @@ -5989,12 +6823,11 @@ def test_compare_max_containment_ani(c): print(c.last_result.err) print(c.last_result.out) assert "WARNING: Some of these sketches may have no hashes in common based on chance alone (false negatives). Consider decreasing your scaled value to prevent this." in c.last_result.err - assert "WARNING: size estimation for at least one of these sketches may be inaccurate. ANI values will be set to 0 for these comparisons." in c.last_result.err -@utils.in_tempdir -def test_compare_avg_containment_ani(c): - import numpy +def test_compare_avg_containment_ani(runtmp): + # test compare --avg-containment --ani + c = runtmp sigfiles = ["2.fa.sig", "2+63.fa.sig", "47.fa.sig", "63.fa.sig"] testdata_sigs = [utils.get_test_data(c) for c in sigfiles] @@ -6002,23 +6835,9 @@ def test_compare_avg_containment_ani(c): c.run_sourmash('compare', '--avg-containment', '-k', '31', '--estimate-ani', '--csv', 'output.csv', *testdata_sigs) - # load the matrix output of compare --max-containment --estimate-ani - with open(c.output('output.csv'), 'rt') as fp: - r = iter(csv.reader(fp)) - headers = next(r) - - mat = numpy.zeros((len(headers), len(headers))) - for i, row in enumerate(r): - for j, val in enumerate(row): - mat[i][j] = float(val) - - print(mat) - - # load in all the input signatures - idx_to_sig = dict() - for idx, filename in enumerate(testdata_sigs): - ss = sourmash.load_one_signature(filename, ksize=31) - idx_to_sig[idx] = ss + # load the matrix output + mat, idx_to_sig = _load_compare_matrix_and_sigs(c.output('output.csv'), + testdata_sigs) # check explicit avg containment against output of compare for i in range(len(idx_to_sig)): @@ -6041,11 +6860,12 @@ def test_compare_avg_containment_ani(c): print(c.last_result.err) print(c.last_result.out) assert "WARNING: Some of these sketches may have no hashes in common based on chance alone (false negatives). Consider decreasing your scaled value to prevent this." in c.last_result.err - assert "WARNING: size estimation for at least one of these sketches may be inaccurate. ANI values will be set to 0 for these comparisons." in c.last_result.err -@utils.in_tempdir -def test_compare_ANI_require_scaled(c): +def test_compare_ANI_require_scaled(runtmp): + # check that compare with containment requires scaled sketches + c = runtmp + s47 = utils.get_test_data('num/47.fa.sig') s63 = utils.get_test_data('num/63.fa.sig') diff --git a/tests/test_sourmash_args.py b/tests/test_sourmash_args.py index 969a6b6df8..7bb17b337d 100644 --- a/tests/test_sourmash_args.py +++ b/tests/test_sourmash_args.py @@ -1,17 +1,22 @@ """ Tests for functions in sourmash_args module. """ +import sys import os import pytest import gzip import zipfile import io import contextlib +import csv +import argparse +import shutil import sourmash_tst_utils as utils import sourmash from sourmash import sourmash_args, manifest from sourmash.index import LinearIndex +from sourmash.cli.utils import add_ksize_arg def test_save_signatures_api_none(): @@ -135,6 +140,27 @@ def test_save_signatures_to_location_1_zip(runtmp): assert len(saved) == 2 +def test_save_signatures_to_location_1_zip_bad(runtmp): + # try saving to bad sigfile.zip + sig2 = utils.get_test_data('2.fa.sig') + ss2 = sourmash.load_one_signature(sig2, ksize=31) + sig47 = utils.get_test_data('47.fa.sig') + ss47 = sourmash.load_one_signature(sig47, ksize=31) + + outloc = runtmp.output('foo.zip') + + # create bad zip: + with open(outloc, 'wt') as fp: + pass + + # now check for error + with pytest.raises(ValueError) as exc: + with sourmash_args.SaveSignaturesToLocation(outloc) as save_sig: + pass + + assert 'cannot be opened as a zip file' in str(exc) + + def test_save_signatures_to_location_1_zip_dup(runtmp): # save to sigfile.zip sig2 = utils.get_test_data('2.fa.sig') @@ -150,8 +176,11 @@ def test_save_signatures_to_location_1_zip_dup(runtmp): # here we have to change the names so the sig content is different; # exact duplicates will not be saved, otherwise. + ss2 = ss2.to_mutable() ss2.name = 'different name for ss2' save_sig.add(ss2) + + ss47 = ss47.to_mutable() ss47.name = 'different name for ss47' save_sig.add(ss47) @@ -227,7 +256,7 @@ def test_save_signatures_to_location_2_zip_add_dup(runtmp): # add ss2; here we have to change the names so the sig content is # different exact duplicates will not be saved, otherwise. import copy - ss2copy = copy.copy(ss2) + ss2copy = ss2.to_mutable() ss2copy.name = 'different name for ss2' save_sig.add(ss2copy) @@ -565,3 +594,224 @@ def test_pattern_5(): with pytest.raises(SystemExit): pattern_search = sourmash_args.load_include_exclude_db_patterns(args) + + +def test_fileinput_csv_1_plain(): + # test basic CSV input + + testfile = utils.get_test_data('tax/test.taxonomy.csv') + + with sourmash_args.FileInputCSV(testfile) as r: + rows = list(r) + assert len(rows) == 6 + + +def test_fileinput_csv_1_no_such_file(runtmp): + # test fail to load file + + noexistfile = runtmp.output('does-not-exist.csv') + + with pytest.raises(FileNotFoundError): + with sourmash_args.FileInputCSV(noexistfile) as r: + pass + + +def test_fileinput_csv_2_gz(runtmp): + # test basic CSV input from gz file + + testfile = utils.get_test_data('tax/test.taxonomy.csv') + gzfile = runtmp.output('test.csv.gz') + + with gzip.open(gzfile, 'wt') as outfp: + with open(testfile, 'rt', newline='') as infp: + outfp.write(infp.read()) + + with sourmash_args.FileInputCSV(gzfile) as r: + rows = list(r) + assert len(rows) == 6 + + +def test_fileinput_csv_2_gz_not_csv(runtmp): + # test basic CSV input from gz file that's not CSV - works + + gzfile = runtmp.output('test.csv.gz') + + with gzip.open(gzfile, 'wt') as outfp: + outfp.write("hello world!") + + with sourmash_args.FileInputCSV(gzfile) as r: + assert r.fieldnames == ['hello world!'] + + +def test_fileinput_csv_2_gz_bad_version_header(runtmp): + # test basic CSV input from gz file with bad version header + # currently this works; not clear to me how it should fail :grin: + + gzfile = runtmp.output('test.csv.gz') + + with gzip.open(gzfile, 'wt') as outfp: + outfp.write("# excelsior\nhello world!") + + with sourmash_args.FileInputCSV(gzfile) as r: + assert r.fieldnames == ['hello world!'] + print(r.version_info) + assert r.version_info == ['excelsior'] + + +def test_fileinput_csv_2_zip(runtmp): + # test CSV input from zip file, with component filename + + testfile = utils.get_test_data('tax/test.taxonomy.csv') + zf_file = runtmp.output('test.zip') + + with zipfile.ZipFile(zf_file, 'w') as outzip: + with open(testfile, 'rb') as infp: + with outzip.open('XYZ.csv', 'w') as outfp: + outfp.write(infp.read()) + + with sourmash_args.FileInputCSV(zf_file, default_csv_name='XYZ.csv') as r: + rows = list(r) + assert len(rows) == 6 + print(rows) + + +def test_fileinput_csv_3_load_manifest(): + # test loading a manifest from a zipfile collection, using + # FileInputCSV. + testfile = utils.get_test_data('prot/all.zip') + + with sourmash_args.FileInputCSV(testfile, default_csv_name='SOURMASH-MANIFEST.csv') as r: + + rows = list(r) + assert len(rows) == 8 + + assert r.version_info == ['SOURMASH-MANIFEST-VERSION', '1.0'] + + +def test_fileinput_csv_3_load_manifest_no_default(): + # test loading a manifest from a zipfile collection, using + # FileInputCSV, but with no default_csv_name - should fail + testfile = utils.get_test_data('prot/all.zip') + + with pytest.raises(csv.Error): + with sourmash_args.FileInputCSV(testfile) as r: + print(r.fieldnames) + + +def test_fileinput_csv_3_load_manifest_zipfile_obj(): + # test loading a manifest from an open zipfile obj, using + # FileInputCSV. + testfile = utils.get_test_data('prot/all.zip') + + with zipfile.ZipFile(testfile, "r") as zf: + with sourmash_args.FileInputCSV(testfile, + default_csv_name='SOURMASH-MANIFEST.csv', + zipfile_obj=zf) as r: + rows = list(r) + assert len(rows) == 8 + + assert r.version_info == ['SOURMASH-MANIFEST-VERSION', '1.0'] + + +def test_fileinput_csv_3_load_manifest_zipfile_obj_no_defualt(): + # test loading a manifest from an open zipfile obj, using + # FileInputCSV, but with no default csv name => should fail. + testfile = utils.get_test_data('prot/all.zip') + + with zipfile.ZipFile(testfile, "r") as zf: + with pytest.raises(ValueError): + with sourmash_args.FileInputCSV(testfile, + zipfile_obj=zf) as r: + pass + + +def test_fileoutput_csv_1(runtmp): + # test basic behavior + outfile = runtmp.output('xxx.csv') + + with sourmash_args.FileOutputCSV(outfile) as fp: + w = csv.writer(fp) + w.writerow(['a', 'b', 'c']) + w.writerow(['x', 'y', 'z']) + + with open(outfile, newline="") as fp: + r = csv.DictReader(fp) + rows = list(r) + assert len(rows) == 1 + row = rows[0] + assert row['a'] == 'x' + assert row['b'] == 'y' + assert row['c'] == 'z' + + +def test_fileoutput_csv_1_gz(runtmp): + # test basic behavior => gz + outfile = runtmp.output('xxx.csv.gz') + + with sourmash_args.FileOutputCSV(outfile) as fp: + w = csv.writer(fp) + w.writerow(['a', 'b', 'c']) + w.writerow(['x', 'y', 'z']) + + with gzip.open(outfile, 'rt') as fp: + r = csv.DictReader(fp) + rows = list(r) + assert len(rows) == 1 + row = rows[0] + assert row['a'] == 'x' + assert row['b'] == 'y' + assert row['c'] == 'z' + + +def test_fileoutput_csv_2_stdout(): + # test '-' and 'None' go to sys.stdout + + with sourmash_args.FileOutputCSV('-') as fp: + assert fp == sys.stdout + + with sourmash_args.FileOutputCSV(None) as fp: + assert fp == sys.stdout + + +def test_add_ksize_arg_no_default(): + # test behavior of cli.utils.add_ksize_arg + p = argparse.ArgumentParser() + add_ksize_arg(p) + args = p.parse_args() + assert args.ksize == None + + +def test_add_ksize_arg_no_default_specify(): + # test behavior of cli.utils.add_ksize_arg + p = argparse.ArgumentParser() + add_ksize_arg(p) + args = p.parse_args(['-k', '21']) + assert args.ksize == 21 + + +def test_add_ksize_arg_default_31(): + # test behavior of cli.utils.add_ksize_arg + p = argparse.ArgumentParser() + add_ksize_arg(p, default=31) + args = p.parse_args() + assert args.ksize == 31 + + +def test_add_ksize_arg_default_31_specify(): + # test behavior of cli.utils.add_ksize_arg + p = argparse.ArgumentParser() + add_ksize_arg(p, default=31) + args = p.parse_args(['-k', '21']) + assert args.ksize == 21 + + +def test_bug_2370(runtmp): + # bug - manifest loading code does not catch gzip.BadGzipFile + sigfile = utils.get_test_data('63.fa.sig') + + # copy sigfile over to a .gz file without compressing it - + shutil.copyfile(sigfile, runtmp.output('not_really_gzipped.gz')) + + # try running sourmash_args.load_file_as_index + #runtmp.sourmash('sig', 'describe', runtmp.output('not_really_gzipped.gz')) + sourmash_args.load_file_as_index(runtmp.output('not_really_gzipped.gz')) diff --git a/tests/test_sourmash_sketch.py b/tests/test_sourmash_sketch.py index ba4aeb949a..e48f43216b 100644 --- a/tests/test_sourmash_sketch.py +++ b/tests/test_sourmash_sketch.py @@ -213,6 +213,37 @@ def test_dna_defaults(): assert not params.hp assert not params.protein + siglist = factory() + sig = siglist[0] + sig.minhash + + +def test_dna_multiple_ksize(): + factory = _signatures_for_sketch_factory(['k=21,k=31,k=51'], 'dna') + params_list = list(factory.get_compute_params()) + + assert len(params_list) == 1 + params = params_list[0] + + assert params.ksizes == [21,31,51] + assert params.num_hashes == 0 + assert params.scaled == 1000 + assert not params.track_abundance + assert params.seed == 42 + assert params.dna + assert not params.dayhoff + assert not params.hp + assert not params.protein + + from sourmash.save_load import _get_signatures_from_rust + + siglist = factory() + ksizes = set() + for ss in _get_signatures_from_rust(siglist): + ksizes.add(ss.minhash.ksize) + + assert ksizes == {21, 31, 51} + def test_dna_override_1(): factory = _signatures_for_sketch_factory(['k=21,scaled=2000,abund'], @@ -268,6 +299,7 @@ def test_dna_override_bad_2(): with pytest.raises(ValueError): factory = _signatures_for_sketch_factory(['k=21,protein'], 'dna') + def test_protein_defaults(): factory = _signatures_for_sketch_factory([], 'protein') params_list = list(factory.get_compute_params()) @@ -334,6 +366,7 @@ def test_dayhoff_override_bad_2(): with pytest.raises(ValueError): factory = _signatures_for_sketch_factory(['k=21,dna'], 'dayhoff') + def test_hp_defaults(): factory = _signatures_for_sketch_factory([], 'hp') params_list = list(factory.get_compute_params()) @@ -849,6 +882,22 @@ def test_do_sourmash_sketchdna_multik(runtmp): assert 31 in ksizes +def test_do_sourmash_sketchdna_multik_output(runtmp, sig_save_extension): + testdata1 = utils.get_test_data('short.fa') + outfile = runtmp.output(f'out.{sig_save_extension}') + runtmp.sourmash('sketch', 'dna', '-p', 'k=31,k=21', testdata1, + '-o', outfile) + + print("saved to file/path with extension:", outfile) + assert os.path.exists(outfile) + + siglist = list(sourmash.load_file_as_signatures(outfile)) + assert len(siglist) == 2 + ksizes = set([ x.minhash.ksize for x in siglist ]) + assert 21 in ksizes + assert 31 in ksizes + + def test_do_sketch_dna_override_protein_fail(runtmp): testdata1 = utils.get_test_data('short.fa') @@ -1553,6 +1602,33 @@ def test_fromfile_dna(runtmp): assert "** 1 total requested; output 1, skipped 0" in runtmp.last_result.err +def test_fromfile_dna_csv_gz(runtmp): + # test with a gzipped csv + test_inp = utils.get_test_data('sketch_fromfile') + shutil.copytree(test_inp, runtmp.output('sketch_fromfile')) + + # gzip the CSV file + with open(runtmp.output('sketch_fromfile/salmonella.csv'), 'rb') as infp: + with gzip.open(runtmp.output('salmonella.csv.gz'), 'w') as outfp: + outfp.write(infp.read()) + + runtmp.sourmash('sketch', 'fromfile', 'salmonella.csv.gz', + '-o', 'out.zip', '-p', 'dna') + + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert os.path.exists(runtmp.output('out.zip')) + idx = sourmash.load_file_as_index(runtmp.output('out.zip')) + siglist = list(idx.signatures()) + + assert len(siglist) == 1 + ss = siglist[0] + assert ss.name == 'GCA_903797575 Salmonella enterica' + assert ss.minhash.moltype == 'DNA' + assert "** 1 total requested; output 1, skipped 0" in runtmp.last_result.err + + def test_fromfile_dna_empty(runtmp): # test what happens on empty files. test_inp = utils.get_test_data('sketch_fromfile') @@ -1720,6 +1796,28 @@ def test_fromfile_dna_and_protein_dup_name(runtmp): print(out) print(err) + assert "GCA_903797575 Salmonella enterica" not in err + assert "ERROR: 1 entries have duplicate 'name' records. Exiting!" in err + + +def test_fromfile_dna_and_protein_dup_name_report(runtmp): + # duplicate names + test_inp = utils.get_test_data('sketch_fromfile') + shutil.copytree(test_inp, runtmp.output('sketch_fromfile')) + + with pytest.raises(SourmashCommandFailed): + runtmp.sourmash('sketch', 'fromfile', + 'sketch_fromfile/salmonella.csv', + 'sketch_fromfile/salmonella.csv', + '--report-duplicated', + '-o', 'out.zip', '-p', 'dna', '-p', 'protein') + + out = runtmp.last_result.out + err = runtmp.last_result.err + + print(out) + print(err) + assert "GCA_903797575 Salmonella enterica" in err assert "ERROR: 1 entries have duplicate 'name' records. Exiting!" in err diff --git a/tests/test_sqlite_index.py b/tests/test_sqlite_index.py index d524478cf9..74c4692c06 100644 --- a/tests/test_sqlite_index.py +++ b/tests/test_sqlite_index.py @@ -795,6 +795,17 @@ def test_sqlite_lca_db_load_existing(): assert len(siglist) == 2 +def test_sqlite_lca_db_select(): + # try loading an existing sqlite index + filename = utils.get_test_data('sqlite/lca.sqldb') + sqlidx = sourmash.load_file_as_index(filename) + assert isinstance(sqlidx, LCA_SqliteDatabase) + + sqlidx2 = sqlidx.select(ksize=31) + x = list(sqlidx2.hashvals) # only on LCA_SqliteDatabase + assert isinstance(sqlidx2, LCA_SqliteDatabase) + + def test_sqlite_lca_db_create_load_existing(runtmp): # try creating (from CLI) then loading (from API) an LCA db filename = runtmp.output('lca.sqldb') @@ -833,6 +844,33 @@ def test_sqlite_lca_db_load_empty(runtmp): assert 'loaded 0 signatures' in runtmp.last_result.err +def test_sqlite_lca_db_create_readonly(runtmp): + # try running 'prepare' on a read-only sqlite db, check error message. + + dbname = runtmp.output('empty.sqldb') + + # create empty SqliteIndex... + runtmp.sourmash('sig', 'cat', '-o', dbname) + assert os.path.exists(dbname) + + # make it read only... + from stat import S_IREAD, S_IRGRP, S_IROTH + os.chmod(dbname, S_IREAD|S_IRGRP|S_IROTH) + + # ...and try creating empty sourmash_taxonomy tables in there... + empty_tax = utils.get_test_data('scaled/empty-lineage.csv') + + with pytest.raises(SourmashCommandFailed) as exc: + runtmp.sourmash('tax', 'prepare', '-F', 'sql', '-t', empty_tax, + '-o', dbname) + + err = runtmp.last_result.err + print(err) + + assert not "taxonomy table already exists in" in err + assert "attempt to write a readonly database" in err + + def test_sqlite_lca_db_try_load_sqlite_index(): # try loading a SqliteIndex with no tax tables from .load classmethod dbname = utils.get_test_data('sqlite/index.sqldb') diff --git a/tests/test_tax.py b/tests/test_tax.py index 1faf6ce19a..1c980a66ae 100644 --- a/tests/test_tax.py +++ b/tests/test_tax.py @@ -4,13 +4,18 @@ import os import csv import pytest +import gzip +from collections import Counter +import sourmash import sourmash_tst_utils as utils from sourmash.tax import tax_utils +from sourmash.lca import lca_utils from sourmash_tst_utils import SourmashCommandFailed from sourmash import sqlite_utils from sourmash.exceptions import IndexNotSupported +from sourmash import sourmash_args ## command line tests def test_run_sourmash_tax(): @@ -114,7 +119,7 @@ def test_metagenome_summary_csv_out(runtmp): assert os.path.exists(csvout) sum_gather_results = [x.rstrip() for x in open(csvout)] - assert f"saving `csv_summary` output to {csvout}" in runtmp.last_result.err + assert f"saving 'csv_summary' output to '{csvout}'" in runtmp.last_result.err assert 'query_name,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in sum_gather_results[0] assert 'test1,superkingdom,0.2042281611487834,d__Bacteria,md5,test1.sig,0.13080306238801107,1024000' in sum_gather_results[1] assert 'test1,superkingdom,0.7957718388512166,unclassified,md5,test1.sig,0.8691969376119889,3990000' in sum_gather_results[2] @@ -140,6 +145,218 @@ def test_metagenome_summary_csv_out(runtmp): assert 'test1,species,0.7957718388512166,unclassified,md5,test1.sig,0.8691969376119889,3990000' in sum_gather_results[22] +def test_metagenome_summary_csv_out_empty_gather_force(runtmp): + # test multiple -g, empty -g file, and --force + g_csv = utils.get_test_data('tax/test1.gather.csv') + tax = utils.get_test_data('tax/test.taxonomy.csv') + csv_base = "out" + sum_csv = csv_base + ".summarized.csv" + csvout = runtmp.output(sum_csv) + outdir = os.path.dirname(csvout) + + gather_empty = runtmp.output('g.csv') + with open(gather_empty, "w") as fp: + fp.write("") + print("g_csv: ", gather_empty) + + runtmp.run_sourmash('tax', 'metagenome', '--gather-csv', g_csv, '-g', gather_empty, '--taxonomy-csv', tax, '-o', csv_base, '--output-dir', outdir, '-f') + sum_gather_results = [x.rstrip() for x in open(csvout)] + assert f"saving 'csv_summary' output to '{csvout}'" in runtmp.last_result.err + assert 'query_name,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in sum_gather_results[0] + assert 'test1,superkingdom,0.2042281611487834,d__Bacteria,md5,test1.sig,0.13080306238801107,1024000' in sum_gather_results[1] + + +def test_metagenome_kreport_out(runtmp): + # test 'kreport' kraken output format + g_csv = utils.get_test_data('tax/test1.gather.v450.csv') + tax = utils.get_test_data('tax/test.taxonomy.csv') + csv_base = "out" + sum_csv = csv_base + ".kreport.txt" + csvout = runtmp.output(sum_csv) + outdir = os.path.dirname(csvout) + + runtmp.run_sourmash('tax', 'metagenome', '--gather-csv', g_csv, '--taxonomy-csv', tax, '-o', csv_base, '--output-dir', outdir, '-F', "kreport") + + print(runtmp.last_result.status) + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert runtmp.last_result.status == 0 + assert os.path.exists(csvout) + + kreport_results = [x.rstrip().split('\t') for x in open(csvout)] + assert f"saving 'kreport' output to '{csvout}'" in runtmp.last_result.err + print(kreport_results) + assert ['13.08', '1605999', '0', 'D', '', 'd__Bacteria'] == kreport_results[0] + assert ['86.92', '10672000', '10672000', 'U', '', 'unclassified'] == kreport_results[1] + assert ['7.27', '892000', '0', 'P', '', 'p__Bacteroidota'] == kreport_results[2] + assert ['5.82', '714000', '0', 'P', '', 'p__Proteobacteria'] == kreport_results[3] + assert ['7.27', '892000', '0', 'C', '', 'c__Bacteroidia'] == kreport_results[4] + assert ['5.82', '714000', '0', 'C', '', 'c__Gammaproteobacteria'] == kreport_results[5] + assert ['7.27', '892000', '0', 'O', '', 'o__Bacteroidales'] == kreport_results[6] + assert ['5.82', '714000', '0', 'O', '', 'o__Enterobacterales'] == kreport_results[7] + assert ['7.27', '892000', '0', 'F', '', 'f__Bacteroidaceae'] == kreport_results[8] + assert ['5.82', '714000', '0', 'F', '', 'f__Enterobacteriaceae'] == kreport_results[9] + assert ['5.70', '700000', '0', 'G', '', 'g__Prevotella'] == kreport_results[10] + assert ['5.82', '714000', '0', 'G', '', 'g__Escherichia'] == kreport_results[11] + assert ['1.56', '192000', '0', 'G', '', 'g__Phocaeicola'] == kreport_results[12] + assert ['5.70', '700000', '700000', 'S', '', 's__Prevotella copri'] == kreport_results[13] + assert ['5.82', '714000', '714000', 'S', '', 's__Escherichia coli']== kreport_results[14] + assert ['1.56', '192000', '192000', 'S', '', 's__Phocaeicola vulgatus'] == kreport_results[15] + + +def test_metagenome_kreport_ncbi_taxid_out(runtmp): + # test NCBI taxid output from kreport + g_csv = utils.get_test_data('tax/test1.gather.v450.csv') + tax = utils.get_test_data('tax/test.ncbi-taxonomy.csv') + csv_base = "out" + sum_csv = csv_base + ".kreport.txt" + csvout = runtmp.output(sum_csv) + outdir = os.path.dirname(csvout) + + runtmp.run_sourmash('tax', 'metagenome', '--gather-csv', g_csv, '--taxonomy-csv', tax, '-o', csv_base, '--output-dir', outdir, '-F', "kreport") + + print(runtmp.last_result.status) + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert runtmp.last_result.status == 0 + assert os.path.exists(csvout) + + kreport_results = [x.rstrip().split('\t') for x in open(csvout)] + assert f"saving 'kreport' output to '{csvout}'" in runtmp.last_result.err + print(kreport_results) + assert ['13.08', '1605999', '0', 'D', '2', 'Bacteria'] == kreport_results[0] + assert ['86.92', '10672000', '10672000', 'U', '', 'unclassified'] == kreport_results[1] + assert ['7.27', '892000', '0', 'P', '976', 'Bacteroidota'] == kreport_results[2] + assert ['5.82', '714000', '0', 'P', '1224', 'Pseudomonadota'] == kreport_results[3] + assert ['7.27', '892000', '0', 'C', '200643', 'Bacteroidia'] == kreport_results[4] + assert ['5.82', '714000', '0', 'C', '1236', 'Gammaproteobacteria'] == kreport_results[5] + assert ['7.27', '892000', '0', 'O', '171549', 'Bacteroidales'] == kreport_results[6] + assert ['5.82', '714000', '0', 'O', '91347', 'Enterobacterales'] == kreport_results[7] + assert ['5.70', '700000', '0', 'F', '171552', 'Prevotellaceae'] == kreport_results[8] + assert ['5.82', '714000', '0', 'F', '543', 'Enterobacteriaceae'] == kreport_results[9] + assert ['1.56', '192000', '0', 'F', '815', 'Bacteroidaceae'] == kreport_results[10] + assert ['5.70', '700000', '0', 'G', '838', 'Prevotella'] == kreport_results[11] + assert ['5.82', '714000', '0', 'G', '561', 'Escherichia'] == kreport_results[12] + assert ['1.56', '192000', '0', 'G', '909656', 'Phocaeicola'] == kreport_results[13] + assert ['5.70', '700000', '700000', 'S', '165179', 'Prevotella copri'] == kreport_results[14] + assert ['5.82', '714000', '714000', 'S', '562', 'Escherichia coli'] == kreport_results[15] + assert ['1.56', '192000', '192000', 'S', '821', 'Phocaeicola vulgatus'] == kreport_results[16] + + +def test_metagenome_kreport_out_lemonade(runtmp): + # test 'kreport' kraken output format against lemonade output + g_csv = utils.get_test_data('tax/lemonade-MAG3.x.gtdb.csv') + tax = utils.get_test_data('tax/lemonade-MAG3.x.gtdb.matches.tax.csv') + csv_base = "out" + sum_csv = csv_base + ".kreport.txt" + csvout = runtmp.output(sum_csv) + outdir = os.path.dirname(csvout) + + runtmp.run_sourmash('tax', 'metagenome', '--gather-csv', g_csv, '--taxonomy-csv', tax, '-o', csv_base, '--output-dir', outdir, '-F', "kreport") + + print(runtmp.last_result.status) + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert runtmp.last_result.status == 0 + assert os.path.exists(csvout) + + kreport_results = [x.rstrip().split('\t') for x in open(csvout)] + assert f"saving 'kreport' output to '{csvout}'" in runtmp.last_result.err + print(kreport_results) + assert ['5.35', '116000', '0', 'D', '', 'd__Bacteria'] == kreport_results[0] + assert ['94.65', '2054000', '2054000', 'U', '', 'unclassified'] == kreport_results[1] + assert ['5.35', '116000', '0', 'P', '', 'p__Bacteroidota'] == kreport_results[2] + assert ['5.35', '116000', '0', 'C', '', 'c__Chlorobia'] == kreport_results[3] + assert ['5.35', '116000', '0', 'O', '', 'o__Chlorobiales'] == kreport_results[4] + assert ['5.35', '116000', '0', 'F', '', 'f__Chlorobiaceae'] == kreport_results[5] + assert ['5.35', '116000', '0', 'G', '', 'g__Prosthecochloris'] == kreport_results[6] + assert ['5.35', '116000', '116000', 'S', '', 's__Prosthecochloris vibrioformis'] == kreport_results[7] + + +def test_metagenome_kreport_out_fail(runtmp): + # kreport cannot be generated with gather results from < v4.5.0 + g_csv = utils.get_test_data('tax/test1.gather.csv') + tax = utils.get_test_data('tax/test.taxonomy.csv') + csv_base = "out" + sum_csv = csv_base + ".kreport.txt" + csvout = runtmp.output(sum_csv) + outdir = os.path.dirname(csvout) + + with pytest.raises(SourmashCommandFailed): + runtmp.run_sourmash('tax', 'metagenome', '--gather-csv', g_csv, '--taxonomy-csv', tax, '-o', csv_base, '--output-dir', outdir, '-F', "kreport") + + print(runtmp.last_result.status) + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert "ERROR: cannot produce 'kreport' format from gather results before sourmash v4.5.0" in runtmp.last_result.err + + +def test_metagenome_bioboxes_stdout(runtmp): + # test CAMI bioboxes format output + g_csv = utils.get_test_data('tax/test1.gather.v450.csv') + tax = utils.get_test_data('tax/test.ncbi-taxonomy.csv') + + runtmp.run_sourmash('tax', 'metagenome', '--gather-csv', g_csv, '--taxonomy-csv', tax, '-F', "bioboxes") + + print(runtmp.last_result.status) + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert runtmp.last_result.status == 0 + + assert "# Taxonomic Profiling Output" in runtmp.last_result.out + assert "@SampleID:test1" in runtmp.last_result.out + assert "@Version:0.10.0" in runtmp.last_result.out + assert "@Ranks:superkingdom|phylum|class|order|family|genus|species|strain" in runtmp.last_result.out + assert "@__program__:sourmash" in runtmp.last_result.out + assert "2 superkingdom 2 Bacteria 13.08" in runtmp.last_result.out + assert "976 phylum 2|976 Bacteria|Bacteroidota 7.27" in runtmp.last_result.out + assert "1224 phylum 2|1224 Bacteria|Pseudomonadota 5.82" in runtmp.last_result.out + assert "200643 class 2|976|200643 Bacteria|Bacteroidota|Bacteroidia 7.27" in runtmp.last_result.out + assert "1236 class 2|1224|1236 Bacteria|Pseudomonadota|Gammaproteobacteria 5.82" in runtmp.last_result.out + assert "171549 order 2|976|200643|171549 Bacteria|Bacteroidota|Bacteroidia|Bacteroidales 7.27" in runtmp.last_result.out + assert "91347 order 2|1224|1236|91347 Bacteria|Pseudomonadota|Gammaproteobacteria|Enterobacterales 5.82" in runtmp.last_result.out + assert "171552 family 2|976|200643|171549|171552 Bacteria|Bacteroidota|Bacteroidia|Bacteroidales|Prevotellaceae 5.70" in runtmp.last_result.out + assert "543 family 2|1224|1236|91347|543 Bacteria|Pseudomonadota|Gammaproteobacteria|Enterobacterales|Enterobacteriaceae 5.82" in runtmp.last_result.out + assert "815 family 2|976|200643|171549|815 Bacteria|Bacteroidota|Bacteroidia|Bacteroidales|Bacteroidaceae 1.56" in runtmp.last_result.out + assert "838 genus 2|976|200643|171549|171552|838 Bacteria|Bacteroidota|Bacteroidia|Bacteroidales|Prevotellaceae|Prevotella 5.70" in runtmp.last_result.out + assert "561 genus 2|1224|1236|91347|543|561 Bacteria|Pseudomonadota|Gammaproteobacteria|Enterobacterales|Enterobacteriaceae|Escherichia 5.82" in runtmp.last_result.out + assert "909656 genus 2|976|200643|171549|815|909656 Bacteria|Bacteroidota|Bacteroidia|Bacteroidales|Bacteroidaceae|Phocaeicola 1.56" in runtmp.last_result.out + assert "165179 species 2|976|200643|171549|171552|838|165179 Bacteria|Bacteroidota|Bacteroidia|Bacteroidales|Prevotellaceae|Prevotella|Prevotella copri 5.70" in runtmp.last_result.out + assert "562 species 2|1224|1236|91347|543|561|562 Bacteria|Pseudomonadota|Gammaproteobacteria|Enterobacterales|Enterobacteriaceae|Escherichia|Escherichia coli 5.82" in runtmp.last_result.out + assert "821 species 2|976|200643|171549|815|909656|821 Bacteria|Bacteroidota|Bacteroidia|Bacteroidales|Bacteroidaceae|Phocaeicola|Phocaeicola vulgatus 1.56" in runtmp.last_result.out + + +def test_metagenome_bioboxes_outfile(runtmp): + # test CAMI bioboxes format output + g_csv = utils.get_test_data('tax/test1.gather.v450.csv') + tax = utils.get_test_data('tax/test.ncbi-taxonomy.csv') + csv_base = "out" + sum_csv = csv_base + ".bioboxes.profile" + csvout = runtmp.output(sum_csv) + outdir = os.path.dirname(csvout) + + runtmp.run_sourmash('tax', 'metagenome', '--gather-csv', g_csv, '--taxonomy-csv', tax, '-F', "bioboxes", '-o', csv_base, '--output-dir', outdir,) + + print(runtmp.last_result.status) + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert runtmp.last_result.status == 0 + + bb_results = [x.rstrip().split('\t') for x in open(csvout)] + assert f"saving 'bioboxes' output to '{csvout}'" in runtmp.last_result.err + print(bb_results) + assert ['# Taxonomic Profiling Output'] == bb_results[0] + assert ['@SampleID:test1'] == bb_results[1] + assert ['2', 'superkingdom', '2', 'Bacteria', '13.08'] == bb_results[6] + assert ['838', 'genus', '2|976|200643|171549|171552|838', 'Bacteria|Bacteroidota|Bacteroidia|Bacteroidales|Prevotellaceae|Prevotella', '5.70'] == bb_results[16] + + def test_metagenome_krona_tsv_out(runtmp): g_csv = utils.get_test_data('tax/test1.gather.csv') tax = utils.get_test_data('tax/test.taxonomy.csv') @@ -158,7 +375,7 @@ def test_metagenome_krona_tsv_out(runtmp): assert runtmp.last_result.status == 0 assert os.path.exists(csvout) - assert f"saving `krona` output to {csvout}" in runtmp.last_result.err + assert f"saving 'krona' output to '{csvout}'" in runtmp.last_result.err gn_krona_results = [x.rstrip().split('\t') for x in open(csvout)] print("species krona results: \n", gn_krona_results) @@ -188,7 +405,7 @@ def test_metagenome_lineage_summary_out(runtmp): assert runtmp.last_result.status == 0 assert os.path.exists(csvout) - assert f"saving `lineage_summary` output to {csvout}" in runtmp.last_result.err + assert f"saving 'lineage_summary' output to '{csvout}'" in runtmp.last_result.err gn_lineage_summary = [x.rstrip().split('\t') for x in open(csvout)] print("species lineage summary results: \n", gn_lineage_summary) @@ -199,6 +416,41 @@ def test_metagenome_lineage_summary_out(runtmp): assert ['unclassified', '0.7957718388512166'] == gn_lineage_summary[4] +def test_metagenome_human_format_out(runtmp): + g_csv = utils.get_test_data('tax/test1.gather.csv') + tax = utils.get_test_data('tax/test.taxonomy.csv') + csv_base = "out" + csvout = runtmp.output(csv_base + '.human.txt') + outdir = os.path.dirname(csvout) + print("csvout: ", csvout) + + runtmp.run_sourmash('tax', 'metagenome', '-g', g_csv, '--taxonomy-csv', tax, + '-o', csv_base, '--output-format', 'human', '--rank', + 'genus', '--output-dir', outdir) + + print(runtmp.last_result.status) + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert runtmp.last_result.status == 0 + assert os.path.exists(csvout) + assert f"saving 'human' output to '{csvout}'" in runtmp.last_result.err + + with open(csvout) as fp: + outp = fp.readlines() + + assert len(outp) == 6 + outp = [ x.strip() for x in outp ] + print(outp) + + assert outp[0] == 'sample name proportion cANI lineage' + assert outp[1] == '----------- ---------- ---- -------' + assert outp[2] == 'test1 86.9% - unclassified' + assert outp[3] == 'test1 5.8% 92.5% d__Bacteria;p__Proteobacteria;c__Gammaproteobacteria;o__Enterobacterales;f__Enterobacteriaceae;g__Escherichia' + assert outp[4] == 'test1 5.7% 92.5% d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella' + assert outp[5] == 'test1 1.6% 89.1% d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Phocaeicola' + + def test_metagenome_no_taxonomy_fail(runtmp): c = runtmp g_csv = utils.get_test_data('tax/test1.gather.csv') @@ -215,7 +467,8 @@ def test_metagenome_no_rank_lineage_summary(runtmp): with pytest.raises(SourmashCommandFailed) as exc: runtmp.run_sourmash('tax', 'metagenome', '-g', g_csv, '--taxonomy-csv', tax, '-o', csv_base, '--output-format', 'lineage_summary') - assert "Rank (--rank) is required for krona and lineage_summary output formats." in str(exc.value) + print(str(exc.value)) + assert "Rank (--rank) is required for krona, lineage_summary output formats." in str(exc.value) def test_metagenome_no_rank_krona(runtmp): @@ -225,7 +478,24 @@ def test_metagenome_no_rank_krona(runtmp): with pytest.raises(SourmashCommandFailed) as exc: runtmp.run_sourmash('tax', 'metagenome', '-g', g_csv, '--taxonomy-csv', tax, '-o', csv_base, '--output-format', 'krona') - assert "Rank (--rank) is required for krona and lineage_summary output formats." in str(exc.value) + print(str(exc.value)) + assert "Rank (--rank) is required for krona, lineage_summary output formats." in str(exc.value) + + +def test_metagenome_bad_rank_krona(runtmp): + g_csv = utils.get_test_data('tax/test1.gather.csv') + tax = utils.get_test_data('tax/test.taxonomy.csv') + csv_base = "out" + + with pytest.raises(SourmashCommandFailed) as exc: + runtmp.run_sourmash('tax', 'metagenome', '-g', g_csv, '--taxonomy-csv', tax, '-o', csv_base, '--output-format', 'krona', '--rank', 'NotARank') + print(str(exc.value)) + assert "Invalid '--rank'/'--position' input: 'NotARank'. Please choose: 'strain', 'species', 'genus', 'family', 'order', 'class', 'phylum', 'superkingdom'" in runtmp.last_result.err + + with pytest.raises(SourmashCommandFailed) as exc: + runtmp.run_sourmash('tax', 'metagenome', '-g', g_csv, '--taxonomy-csv', tax, '-o', csv_base, '--output-format', 'krona', '--rank', '5') + print(str(exc.value)) + assert "Invalid '--rank'/'--position' input: '5'. Please choose: 'strain', 'species', 'genus', 'family', 'order', 'class', 'phylum', 'superkingdom'" in runtmp.last_result.err def test_genome_no_rank_krona(runtmp): @@ -235,7 +505,7 @@ def test_genome_no_rank_krona(runtmp): with pytest.raises(SourmashCommandFailed) as exc: runtmp.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', tax, '-o', csv_base, '--output-format', 'krona') - assert "Rank (--rank) is required for krona output format." in str(exc.value) + assert "ERROR: Rank (--rank) is required for krona output formats" in str(exc.value) def test_metagenome_rank_not_available(runtmp): @@ -345,8 +615,8 @@ def test_metagenome_missing_fail_taxonomy(runtmp): print(str(exc.value)) - assert "The following are missing from the taxonomy information: GCF_003471795" in str(exc.value) - assert "Failing on missing taxonomy, as requested via --fail-on-missing-taxonomy." in str(exc.value) + assert "ident 'GCF_003471795' is not in the taxonomy database." in str(exc.value) + assert "Failing, as requested via --fail-on-missing-taxonomy" in str(exc.value) assert c.last_result.status == -1 @@ -363,7 +633,7 @@ def test_metagenome_multiple_taxonomy_files_missing(runtmp): print(c.last_result.out) print(c.last_result.err) - assert "of 6, missed 2 lineage assignments." in c.last_result.err + assert "of 6 gather results, lineage assignments for 2 results were missed" in c.last_result.err assert 'query_name,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out assert 'multtest,superkingdom,0.204,d__Bacteria,9687eeed,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,0.131,1024000' in c.last_result.out assert 'multtest,superkingdom,0.796,unclassified,9687eeed,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,0.869,3990000' in c.last_result.out @@ -390,7 +660,66 @@ def test_metagenome_multiple_taxonomy_files(runtmp): print(c.last_result.out) print(c.last_result.err) - assert "of 6, missed 0 lineage assignments." in c.last_result.err + assert 'query_name,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out + assert 'multtest,superkingdom,0.204,Bacteria,9687eeed,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,0.131,1024000' in c.last_result.out + assert 'multtest,superkingdom,0.051,Eukaryota,9687eeed,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,0.245,258000' in c.last_result.out + assert 'multtest,superkingdom,0.744,unclassified,9687eeed,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,0.624,3732000' in c.last_result.out + assert 'multtest,phylum,0.116,Bacteria;Bacteroidetes,9687eeed,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,0.073,582000' in c.last_result.out + assert 'multtest,phylum,0.088,Bacteria;Proteobacteria,9687eeed,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,0.058,442000' in c.last_result.out + assert 'multtest,phylum,0.051,Eukaryota;Apicomplexa,9687eeed,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,0.245,258000' in c.last_result.out + assert 'multtest,phylum,0.744,unclassified,9687eeed,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,0.624,3732000' in c.last_result.out + assert 'multtest,class,0.116,Bacteria;Bacteroidetes;Bacteroidia,9687eeed,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,0.073,582000' in c.last_result.out + + +def test_metagenome_multiple_taxonomy_files_multiple_taxonomy_args(runtmp): + c = runtmp + # pass in mult tax files using mult tax arguments + taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') + protozoa_genbank = utils.get_test_data('tax/protozoa_genbank_lineage.csv') + bacteria_refseq = utils.get_test_data('tax/bacteria_refseq_lineage.csv') + + # gather against mult databases + g_csv = utils.get_test_data('tax/test1_x_gtdbrs202_genbank_euks.gather.csv') + + c.run_sourmash('tax', 'metagenome', '-g', g_csv, '--taxonomy-csv', taxonomy_csv, '-t', protozoa_genbank, '-t', bacteria_refseq) + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + + assert 'query_name,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out + assert 'multtest,superkingdom,0.204,Bacteria,9687eeed,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,0.131,1024000' in c.last_result.out + assert 'multtest,superkingdom,0.051,Eukaryota,9687eeed,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,0.245,258000' in c.last_result.out + assert 'multtest,superkingdom,0.744,unclassified,9687eeed,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,0.624,3732000' in c.last_result.out + assert 'multtest,phylum,0.116,Bacteria;Bacteroidetes,9687eeed,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,0.073,582000' in c.last_result.out + assert 'multtest,phylum,0.088,Bacteria;Proteobacteria,9687eeed,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,0.058,442000' in c.last_result.out + assert 'multtest,phylum,0.051,Eukaryota;Apicomplexa,9687eeed,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,0.245,258000' in c.last_result.out + assert 'multtest,phylum,0.744,unclassified,9687eeed,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,0.624,3732000' in c.last_result.out + assert 'multtest,class,0.116,Bacteria;Bacteroidetes;Bacteroidia,9687eeed,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,0.073,582000' in c.last_result.out + + +def test_metagenome_multiple_taxonomy_files_multiple_taxonomy_args_empty_force(runtmp): + # pass in mult tax files using mult tax arguments, with one empty, + # and use --force + c = runtmp + taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') + protozoa_genbank = utils.get_test_data('tax/protozoa_genbank_lineage.csv') + bacteria_refseq = utils.get_test_data('tax/bacteria_refseq_lineage.csv') + + tax_empty = runtmp.output('t.csv') + g_csv = utils.get_test_data('tax/test1.gather.csv') + + with open(tax_empty, "w") as fp: + fp.write("") + print("t_csv: ", tax_empty) + + # gather against mult databases + g_csv = utils.get_test_data('tax/test1_x_gtdbrs202_genbank_euks.gather.csv') + + c.run_sourmash('tax', 'metagenome', '-g', g_csv, '--taxonomy-csv', taxonomy_csv, '-t', protozoa_genbank, '-t', bacteria_refseq, '-t', tax_empty, '--force') + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + assert 'query_name,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out assert 'multtest,superkingdom,0.204,Bacteria,9687eeed,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,0.131,1024000' in c.last_result.out assert 'multtest,superkingdom,0.051,Eukaryota,9687eeed,outputs/abundtrim/HSMA33MX.abundtrim.fq.gz,0.245,258000' in c.last_result.out @@ -414,7 +743,7 @@ def test_metagenome_empty_gather_results(runtmp): with pytest.raises(SourmashCommandFailed) as exc: runtmp.run_sourmash('tax', 'metagenome', '-g', g_csv, '--taxonomy-csv', tax) - assert f'Cannot read gather results from {g_csv}. Is file empty?' in str(exc.value) + assert f"Cannot read gather results from '{g_csv}'. Is file empty?" in str(exc.value) assert runtmp.last_result.status == -1 @@ -425,7 +754,7 @@ def test_metagenome_bad_gather_header(runtmp): bad_g_csv = runtmp.output('g.csv') #creates bad gather result - bad_g = [x.replace("name", "nope") for x in open(g_csv, 'r')] + bad_g = [x.replace("query_bp", "nope") for x in open(g_csv, 'r')] with open(bad_g_csv, 'w') as fp: for line in bad_g: fp.write(line) @@ -434,11 +763,13 @@ def test_metagenome_bad_gather_header(runtmp): with pytest.raises(SourmashCommandFailed) as exc: runtmp.run_sourmash('tax', 'metagenome', '-g', bad_g_csv, '--taxonomy-csv', tax) - assert f'Not all required gather columns are present in {bad_g_csv}.' in str(exc.value) + print(str(exc.value)) + assert 'is missing columns needed for taxonomic summarization.' in str(exc.value) assert runtmp.last_result.status == -1 def test_metagenome_empty_tax_lineage_input(runtmp): + # test an empty tax CSV tax_empty = runtmp.output('t.csv') g_csv = utils.get_test_data('tax/test1.gather.csv') @@ -458,6 +789,27 @@ def test_metagenome_empty_tax_lineage_input(runtmp): assert "cannot read taxonomy assignments from" in str(exc.value) +def test_metagenome_empty_tax_lineage_input_force(runtmp): + # test an empty tax CSV with --force + tax_empty = runtmp.output('t.csv') + g_csv = utils.get_test_data('tax/test1.gather.csv') + + with open(tax_empty, "w") as fp: + fp.write("") + print("t_csv: ", tax_empty) + + + with pytest.raises(SourmashCommandFailed) as exc: + runtmp.run_sourmash('tax', 'metagenome', '-g', g_csv, '--taxonomy-csv', tax_empty, '--force') + + print(runtmp.last_result.status) + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert runtmp.last_result.status != 0 + assert "ERROR: No taxonomic assignments loaded" in str(exc.value) + + def test_metagenome_perfect_match_warning(runtmp): tax = utils.get_test_data('tax/test.taxonomy.csv') g_csv = utils.get_test_data('tax/test1.gather.csv') @@ -489,7 +841,7 @@ def test_metagenome_perfect_match_warning(runtmp): print(runtmp.last_result.err) assert runtmp.last_result.status == 0 - assert 'WARNING: 100% match! Is query "test1" identical to its database match, GCF_001881345' in runtmp.last_result.err + assert "WARNING: 100% match! Is query 'test1' identical to its database match, 'GCF_001881345'?" in runtmp.last_result.err def test_metagenome_over100percent_error(runtmp): @@ -521,7 +873,7 @@ def test_metagenome_over100percent_error(runtmp): print(runtmp.last_result.err) assert runtmp.last_result.status == -1 - assert "ERROR: The tax summary of query 'test1' is 1.1160749900279219, which is > 100% of the query!!" in runtmp.last_result.err + assert "fraction is > 100% of the query! This should not be possible." in runtmp.last_result.err def test_metagenome_gather_duplicate_query(runtmp): @@ -545,6 +897,7 @@ def test_metagenome_gather_duplicate_query(runtmp): def test_metagenome_gather_duplicate_query_force(runtmp): + # do not load same query from multiple files. c = runtmp taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') g_res = utils.get_test_data('tax/test1.gather.csv') @@ -555,82 +908,215 @@ def test_metagenome_gather_duplicate_query_force(runtmp): for line in open(g_res, 'r'): fp.write(line) - c.run_sourmash('tax', 'metagenome', '--gather-csv', g_res, g_res2, + with pytest.raises(SourmashCommandFailed) as exc: + c.run_sourmash('tax', 'metagenome', '--gather-csv', g_res, g_res2, '--taxonomy-csv', taxonomy_csv, '--force') print(c.last_result.status) print(c.last_result.out) print(c.last_result.err) - assert c.last_result.status == 0 - assert '--force is set, ignoring duplicate query.' in c.last_result.err - assert 'No gather results loaded from ' in c.last_result.err - assert 'loaded results from 1 gather CSVs' in c.last_result.err - assert 'query_name,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out - assert 'test1,superkingdom,0.204,d__Bacteria,md5,test1.sig,0.131,1024000' in c.last_result.out - assert 'test1,superkingdom,0.796,unclassified,md5,test1.sig,0.869,3990000' in c.last_result.out - assert 'test1,phylum,0.116,d__Bacteria;p__Bacteroidota,md5,test1.sig,0.073,582000' in c.last_result.out - assert 'test1,phylum,0.088,d__Bacteria;p__Proteobacteria,md5,test1.sig,0.058,442000' in c.last_result.out - assert 'test1,phylum,0.796,unclassified,md5,test1.sig,0.869,3990000' in c.last_result.out + assert c.last_result.status == -1 + assert "Gather query test1 was found in more than one CSV." in c.last_result.err + assert "Cannot force past duplicated gather query. Exiting." in c.last_result.err -def test_metagenome_gather_duplicate_filename(runtmp): + +def test_metagenome_two_queries_human_output(runtmp): + # do not load same query from multiple files. c = runtmp taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') g_res = utils.get_test_data('tax/test1.gather.csv') - c.run_sourmash('tax', 'metagenome', '--gather-csv', g_res, g_res, '--taxonomy-csv', taxonomy_csv) + # make a second query with same output + g_res2 = runtmp.output("test2.gather.csv") + with open(g_res2, 'w') as fp: + for line in open(g_res, 'r'): + line = line.replace('test1', 'test2') + fp.write(line) + + c.run_sourmash('tax', 'metagenome', '--gather-csv', g_res, g_res2, + '--taxonomy-csv', taxonomy_csv, '-F', "human") print(c.last_result.status) print(c.last_result.out) print(c.last_result.err) assert c.last_result.status == 0 - assert f'ignoring duplicated reference to file: {g_res}' - assert 'query_name,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out - assert 'test1,superkingdom,0.204,d__Bacteria,md5,test1.sig,0.131,1024000' in c.last_result.out + assert "test1 86.9% - unclassified" in c.last_result.out + assert "test1 5.8% 92.5% d__Bacteria;p__Proteobacteria;c__Gammaproteobacteria;o__Enterobacterales;f__Enterobacteriaceae;g__Escherichia;s__Escherichia coli" in c.last_result.out + assert "test2 86.9% - unclassified" in c.last_result.out + assert "test2 5.8% 92.5% d__Bacteria;p__Proteobacteria;c__Gammaproteobacteria;o__Enterobacterales;f__Enterobacteriaceae;g__Escherichia;s__Escherichia coli" in c.last_result.out + assert "test2 5.7% 92.5% d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri" + assert "test2 1.6% 89.1% d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Phocaeicola;s__Phocaeicola vulgatus" -def test_metagenome_gather_duplicate_filename_from_file(runtmp): +def test_metagenome_two_queries_with_single_query_output_formats_fail(runtmp): + # fail on multiple queries with single query output formats c = runtmp taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') g_res = utils.get_test_data('tax/test1.gather.csv') - g_from_file = runtmp.output("tmp-from-file.txt") - with open(g_from_file, 'w') as f_csv: - f_csv.write(f"{g_res}\n") - f_csv.write(f"{g_res}\n") - c.run_sourmash('tax', 'metagenome', '--from-file', g_from_file, '--taxonomy-csv', taxonomy_csv) + # make a second query with same output + g_res2 = runtmp.output("test2.gather.csv") + with open(g_res2, 'w') as fp: + for line in open(g_res, 'r'): + line = line.replace('test1', 'test2') + fp.write(line) - print(c.last_result.status) - print(c.last_result.out) - print(c.last_result.err) + csv_summary_out = runtmp.output("tst.summarized.csv") + kreport_out = runtmp.output("tst.kreport.txt") - assert c.last_result.status == 0 - assert f'ignoring duplicated reference to file: {g_res}' - assert 'query_name,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out - assert 'test1,superkingdom,0.204,d__Bacteria,md5,test1.sig,0.131,1024000' in c.last_result.out + with pytest.raises(SourmashCommandFailed) as exc: + c.run_sourmash('tax', 'metagenome', '--gather-csv', g_res, g_res2, + '--taxonomy-csv', taxonomy_csv, '-F', "csv_summary", "kreport", "--rank", "phylum", "-o", "tst") + print(str(exc.value)) + assert not os.path.exists(csv_summary_out) + assert not os.path.exists(kreport_out) -def test_genome_empty_gather_results(runtmp): - tax = utils.get_test_data('tax/test.taxonomy.csv') + assert c.last_result.status == -1 + assert "loaded results for 2 queries from 2 gather CSVs" in c.last_result.err + assert "WARNING: found results for multiple gather queries. Can only output multi-query result formats: skipping csv_summary, kreport" in c.last_result.err + assert "ERROR: No output formats remaining." in c.last_result.err - #creates empty gather result - g_csv = runtmp.output('g.csv') - with open(g_csv, "w") as fp: - fp.write("") - print("g_csv: ", g_csv) - with pytest.raises(SourmashCommandFailed) as exc: - runtmp.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', tax) +def test_metagenome_two_queries_skip_single_query_output_formats(runtmp): + # remove single-query outputs when working with multiple queries + c = runtmp + taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') + g_res = utils.get_test_data('tax/test1.gather.csv') - assert f'Cannot read gather results from {g_csv}. Is file empty?' in str(exc.value) - assert runtmp.last_result.status == -1 + # make a second query with same output + g_res2 = runtmp.output("test2.gather.csv") + with open(g_res2, 'w') as fp: + for line in open(g_res, 'r'): + line = line.replace('test1', 'test2') + fp.write(line) + csv_summary_out = runtmp.output("tst.summarized.csv") + kreport_out = runtmp.output("tst.kreport.txt") + lineage_summary_out = runtmp.output("tst.lineage_summary.tsv") -def test_genome_bad_gather_header(runtmp): - tax = utils.get_test_data('tax/test.taxonomy.csv') - g_csv = utils.get_test_data('tax/test1.gather.csv') + c.run_sourmash('tax', 'metagenome', '--gather-csv', g_res, g_res2, + '--taxonomy-csv', taxonomy_csv, '-F', "csv_summary", "kreport", "lineage_summary", "--rank", "phylum", "-o", "tst") + + assert not os.path.exists(csv_summary_out) + assert not os.path.exists(kreport_out) + assert os.path.exists(lineage_summary_out) + + assert c.last_result.status == 0 + assert "loaded results for 2 queries from 2 gather CSVs" in c.last_result.err + assert "WARNING: found results for multiple gather queries. Can only output multi-query result formats: skipping csv_summary, kreport" in c.last_result.err + + +def test_metagenome_two_queries_krona(runtmp): + # for now, we enable multi-query krona. Is this desired? + c = runtmp + taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') + g_res = utils.get_test_data('tax/test1.gather.csv') + + # make a second query with same output + g_res2 = runtmp.output("test2.gather.csv") + with open(g_res2, 'w') as fp: + for line in open(g_res, 'r'): + line = line.replace('test1', 'test2') + fp.write(line) + + c.run_sourmash('tax', 'metagenome', '--gather-csv', g_res, g_res2, + '--taxonomy-csv', taxonomy_csv, '-F', "krona", '--rank', 'superkingdom') + + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + + assert c.last_result.status == 0 + assert "WARNING: results from more than one query found. Krona summarization not recommended." in c.last_result.err + assert "Percentage assignment will be normalized by the number of queries to maintain range 0-100%" in c.last_result.err + assert "fraction superkingdom" in c.last_result.out + assert "0.2042281611487834 d__Bacteria" in c.last_result.out + assert "0.7957718388512166 unclassified" in c.last_result.out + + +def test_metagenome_gather_duplicate_filename(runtmp): + # test that a duplicate filename is properly flagged, when passed in + # twice to a single -g argument. + c = runtmp + taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') + g_res = utils.get_test_data('tax/test1.gather.csv') + + c.run_sourmash('tax', 'metagenome', '--gather-csv', g_res, g_res, '--taxonomy-csv', taxonomy_csv) + + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + + assert c.last_result.status == 0 + assert f'ignoring duplicated reference to file: {g_res}' + assert 'query_name,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out + assert 'test1,superkingdom,0.204,d__Bacteria,md5,test1.sig,0.131,1024000' in c.last_result.out + + +def test_metagenome_gather_duplicate_filename_2(runtmp): + # test that a duplicate filename is properly flagged, with -g a -g b + c = runtmp + taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') + g_res = utils.get_test_data('tax/test1.gather.csv') + + c.run_sourmash('tax', 'metagenome', '--gather-csv', g_res, '-g', g_res, '--taxonomy-csv', taxonomy_csv) + + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + + assert c.last_result.status == 0 + assert f'ignoring duplicated reference to file: {g_res}' + assert 'query_name,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out + assert 'test1,superkingdom,0.204,d__Bacteria,md5,test1.sig,0.131,1024000' in c.last_result.out + + +def test_metagenome_gather_duplicate_filename_from_file(runtmp): + c = runtmp + taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') + g_res = utils.get_test_data('tax/test1.gather.csv') + g_from_file = runtmp.output("tmp-from-file.txt") + with open(g_from_file, 'w') as f_csv: + f_csv.write(f"{g_res}\n") + f_csv.write(f"{g_res}\n") + + c.run_sourmash('tax', 'metagenome', '--from-file', g_from_file, '--taxonomy-csv', taxonomy_csv) + + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + + assert c.last_result.status == 0 + assert f'ignoring duplicated reference to file: {g_res}' + assert 'query_name,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out + assert 'test1,superkingdom,0.204,d__Bacteria,md5,test1.sig,0.131,1024000' in c.last_result.out + + +def test_genome_empty_gather_results(runtmp): + tax = utils.get_test_data('tax/test.taxonomy.csv') + + #creates empty gather result + g_csv = runtmp.output('g.csv') + with open(g_csv, "w") as fp: + fp.write("") + print("g_csv: ", g_csv) + + with pytest.raises(SourmashCommandFailed) as exc: + runtmp.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', tax) + + assert runtmp.last_result.status == -1 + print(runtmp.last_result.err) + print(runtmp.last_result.out) + assert f"Cannot read gather results from '{g_csv}'. Is file empty?" in str(exc.value) + + +def test_genome_bad_gather_header(runtmp): + tax = utils.get_test_data('tax/test.taxonomy.csv') + g_csv = utils.get_test_data('tax/test1.gather.csv') bad_g_csv = runtmp.output('g.csv') @@ -644,11 +1130,12 @@ def test_genome_bad_gather_header(runtmp): with pytest.raises(SourmashCommandFailed) as exc: runtmp.run_sourmash('tax', 'genome', '-g', bad_g_csv, '--taxonomy-csv', tax) - assert f'Not all required gather columns are present in {bad_g_csv}.' in str(exc.value) + assert 'is missing columns needed for taxonomic summarization.' in str(exc.value) assert runtmp.last_result.status == -1 def test_genome_empty_tax_lineage_input(runtmp): + # test an empty tax csv tax_empty = runtmp.output('t.csv') g_csv = utils.get_test_data('tax/test1.gather.csv') @@ -656,7 +1143,6 @@ def test_genome_empty_tax_lineage_input(runtmp): fp.write("") print("t_csv: ", tax_empty) - with pytest.raises(SourmashCommandFailed) as exc: runtmp.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', tax_empty) @@ -684,7 +1170,7 @@ def test_genome_rank_stdout_0(runtmp): assert c.last_result.status == 0 assert 'query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out - assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000.0' in c.last_result.out + assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000' in c.last_result.out def test_genome_rank_stdout_0_db(runtmp): @@ -703,7 +1189,18 @@ def test_genome_rank_stdout_0_db(runtmp): assert c.last_result.status == 0 assert 'query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out - assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000.0' in c.last_result.out + assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000' in c.last_result.out + + # too stringent of containment threshold: + c.run_sourmash('tax', 'genome', '--gather-csv', g_csv, '--taxonomy-csv', + tax, '--rank', 'species', '--containment-threshold', '1.0') + + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + + assert c.last_result.status == 0 + assert "test1,below_threshold,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000," in c.last_result.out def test_genome_rank_csv_0(runtmp): @@ -726,11 +1223,11 @@ def test_genome_rank_csv_0(runtmp): print(c.last_result.out) print(c.last_result.err) - assert f"saving `classification` output to {csvout}" in runtmp.last_result.err + assert f"saving 'classification' output to '{csvout}'" in runtmp.last_result.err assert c.last_result.status == 0 cl_results = [x.rstrip() for x in open(csvout)] assert 'query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in cl_results[0] - assert 'test1,match,species,0.0885520542481053,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.05701254275940707,444000.0' in cl_results[1] + assert 'test1,match,species,0.0885520542481053,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.05701254275940707,444000' in cl_results[1] def test_genome_rank_krona(runtmp): @@ -753,7 +1250,7 @@ def test_genome_rank_krona(runtmp): print(c.last_result.out) print(c.last_result.err) - assert f"saving `krona` output to {csvout}" in runtmp.last_result.err + assert f"saving 'krona' output to '{csvout}'" in runtmp.last_result.err assert c.last_result.status == 0 kr_results = [x.rstrip().split('\t') for x in open(csvout)] print(kr_results) @@ -761,6 +1258,71 @@ def test_genome_rank_krona(runtmp): assert ['0.0885520542481053', 'd__Bacteria', 'p__Bacteroidota', 'c__Bacteroidia', 'o__Bacteroidales', 'f__Bacteroidaceae', 'g__Prevotella', 's__Prevotella copri'] == kr_results[1] +def test_genome_rank_human_output(runtmp): + # test basic genome - output csv + c = runtmp + + g_csv = utils.get_test_data('tax/test1.gather.csv') + tax = utils.get_test_data('tax/test.taxonomy.csv') + csv_base = "out" + csvout = runtmp.output(csv_base + '.human.txt') + outdir = os.path.dirname(csvout) + print("csvout: ", csvout) + + c.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', tax, + '--rank', 'species', '-o', csv_base, '--containment-threshold', '0', + '--output-format', 'human', '--output-dir', outdir) + + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + + assert f"saving 'human' output to '{csvout}'" in runtmp.last_result.err + assert c.last_result.status == 0 + + with open(csvout) as fp: + outp = fp.readlines() + print(outp) + + assert len(outp) == 3 + outp = [ x.strip() for x in outp ] + + assert outp[0] == 'sample name status proportion cANI lineage' + assert outp[1] == '----------- ------ ---------- ---- -------' + assert outp[2] == 'test1 match 5.7% 92.5% d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri' + + +def test_genome_rank_lineage_csv_output(runtmp): + # test basic genome - output csv + c = runtmp + + g_csv = utils.get_test_data('tax/test1.gather.csv') + tax = utils.get_test_data('tax/test.taxonomy.csv') + csv_base = "out" + csvout = runtmp.output(csv_base + '.lineage.csv') + outdir = os.path.dirname(csvout) + print("csvout: ", csvout) + + c.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', tax, + '--rank', 'species', '-o', csv_base, '--containment-threshold', '0', + '--output-format', 'lineage_csv', '--output-dir', outdir) + + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + + assert f"saving 'lineage_csv' output to '{csvout}'" in runtmp.last_result.err + assert c.last_result.status == 0 + with open(csvout) as fp: + outp = fp.readlines() + + assert len(outp) == 2 + outp = [ x.strip() for x in outp ] + + assert outp[0] == 'ident,superkingdom,phylum,class,order,family,genus,species' + assert outp[1] == 'test1,d__Bacteria,p__Bacteroidota,c__Bacteroidia,o__Bacteroidales,f__Bacteroidaceae,g__Prevotella,s__Prevotella copri' + + def test_genome_gather_from_file_rank(runtmp): c = runtmp taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') @@ -778,7 +1340,7 @@ def test_genome_gather_from_file_rank(runtmp): assert c.last_result.status == 0 assert 'query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out - assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000.0' in c.last_result.out + assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000' in c.last_result.out def test_genome_gather_two_files(runtmp): @@ -802,8 +1364,41 @@ def test_genome_gather_two_files(runtmp): assert c.last_result.status == 0 assert 'query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out - assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000.0' in c.last_result.out - assert 'test2,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test2.sig,0.057,444000.0' in c.last_result.out + assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000' in c.last_result.out + assert 'test2,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test2.sig,0.057,444000' in c.last_result.out + + +def test_genome_gather_two_files_empty_force(runtmp): + # make test2 results (identical to test1 except query_name and filename) + # add an empty file too, with --force -> should work + c = runtmp + taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') + g_res = utils.get_test_data('tax/test1.gather.csv') + + g_empty_csv = runtmp.output('g_empty.csv') + with open(g_empty_csv, "w") as fp: + fp.write("") + print("g_csv: ", g_empty_csv) + + g_res2 = runtmp.output("test2.gather.csv") + test2_results = [x.replace("test1", "test2") for x in open(g_res, 'r')] + with open(g_res2, 'w') as fp: + for line in test2_results: + fp.write(line) + + c.run_sourmash('tax', 'genome', '-g', g_res, g_res2, '-g', g_empty_csv, + '--taxonomy-csv', taxonomy_csv, + '--rank', 'species', '--containment-threshold', '0', + '--force') + + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + + assert c.last_result.status == 0 + assert 'query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out + assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000' in c.last_result.out + assert 'test2,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test2.sig,0.057,444000' in c.last_result.out def test_genome_gather_duplicate_filename(runtmp): @@ -811,7 +1406,7 @@ def test_genome_gather_duplicate_filename(runtmp): taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') g_res = utils.get_test_data('tax/test1.gather.csv') - c.run_sourmash('tax', 'genome', '--gather-csv', g_res, g_res, '--taxonomy-csv', taxonomy_csv, + c.run_sourmash('tax', 'genome', '--gather-csv', g_res, '-g', g_res, '--taxonomy-csv', taxonomy_csv, '--rank', 'species', '--containment-threshold', '0') print(c.last_result.status) @@ -821,7 +1416,7 @@ def test_genome_gather_duplicate_filename(runtmp): assert c.last_result.status == 0 assert f'ignoring duplicated reference to file: {g_res}' assert 'query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out - assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000.0' in c.last_result.out + assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000' in c.last_result.out def test_genome_gather_from_file_duplicate_filename(runtmp): @@ -843,7 +1438,7 @@ def test_genome_gather_from_file_duplicate_filename(runtmp): assert c.last_result.status == 0 assert f'ignoring duplicated reference to file: {g_res}' assert 'query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out - assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000.0' in c.last_result.out + assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000' in c.last_result.out def test_genome_gather_from_file_duplicate_query(runtmp): @@ -886,19 +1481,18 @@ def test_genome_gather_from_file_duplicate_query_force(runtmp): f_csv.write(f"{g_res}\n") f_csv.write(f"{g_res2}\n") - c.run_sourmash('tax', 'genome', '--from-file', g_from_file, '--taxonomy-csv', taxonomy_csv, + with pytest.raises(SourmashCommandFailed) as exc: + c.run_sourmash('tax', 'genome', '--from-file', g_from_file, '--taxonomy-csv', taxonomy_csv, '--rank', 'species', '--containment-threshold', '0', '--force') print(c.last_result.status) print(c.last_result.out) print(c.last_result.err) - assert c.last_result.status == 0 - assert 'query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out - assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000.0' in c.last_result.out - assert '--force is set, ignoring duplicate query.' in c.last_result.err - assert 'No gather results loaded from ' in c.last_result.err - assert 'loaded results from 1 gather CSVs' in c.last_result.err + assert c.last_result.status == -1 + + assert "Gather query test1 was found in more than one CSV." in c.last_result.err + assert "Cannot force past duplicated gather query. Exiting." in c.last_result.err def test_genome_gather_cli_and_from_file(runtmp): @@ -928,8 +1522,8 @@ def test_genome_gather_cli_and_from_file(runtmp): assert c.last_result.status == 0 assert 'query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out - assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000.0' in c.last_result.out - assert 'test2,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test2.sig,0.057,444000.0' in c.last_result.out + assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000' in c.last_result.out + assert 'test2,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test2.sig,0.057,444000' in c.last_result.out def test_genome_gather_cli_and_from_file_duplicate_filename(runtmp): @@ -953,10 +1547,12 @@ def test_genome_gather_cli_and_from_file_duplicate_filename(runtmp): assert c.last_result.status == 0 assert f'ignoring duplicated reference to file: {g_res}' in c.last_result.err assert 'query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out - assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000.0' in c.last_result.out + assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000' in c.last_result.out def test_genome_gather_from_file_below_threshold(runtmp): + # What do we want the results from this to be? I think I initially thought we shouldn't report anything, + # but wouldn't a "below_threshold" + superkingdom result (here, 0.204) be helpful information? c = runtmp taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') g_res = utils.get_test_data('tax/test1.gather.csv') @@ -973,7 +1569,7 @@ def test_genome_gather_from_file_below_threshold(runtmp): assert c.last_result.status == 0 assert "query_name,status,rank,fraction,lineage" in c.last_result.out - assert "test1,below_threshold,,0.000," in c.last_result.out + assert "test1,below_threshold,superkingdom,0.204," in c.last_result.out def test_genome_gather_two_queries(runtmp): @@ -1028,9 +1624,41 @@ def test_genome_rank_duplicated_taxonomy_fail(runtmp): assert "multiple lineages for identifier GCF_001881345" in str(exc.value) -def test_genome_rank_duplicated_taxonomy_force(runtmp): +def test_genome_rank_duplicated_taxonomy_fail_lineages(runtmp): + # write temp taxonomy with duplicates => lineages-style file c = runtmp + + taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') + taxdb = tax_utils.LineageDB.load(taxonomy_csv) + + for k, v in taxdb.items(): + print(k, v) + + lineage_csv = runtmp.output('lin.csv') + with open(lineage_csv, 'w', newline="") as fp: + w = csv.writer(fp) + w.writerow(['name', 'lineage']) + for k, v in taxdb.items(): + linstr = lca_utils.display_lineage(v) + w.writerow([k, linstr]) + + # duplicate each row, changing something (truncate species, here) + v = v[:-1] + linstr = lca_utils.display_lineage(v) + w.writerow([k, linstr]) + + with pytest.raises(SourmashCommandFailed) as exc: + c.run_sourmash('tax', 'summarize', lineage_csv) + print(c.last_result.out) + print(c.last_result.err) + + assert "cannot read taxonomy assignments" in str(exc.value) + assert "multiple lineages for identifier GCF_001881345" in str(exc.value) + + +def test_genome_rank_duplicated_taxonomy_force(runtmp): # write temp taxonomy with duplicates + c = runtmp taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') duplicated_csv = runtmp.output("duplicated_taxonomy.csv") with open(duplicated_csv, 'w') as dup: @@ -1049,7 +1677,7 @@ def test_genome_rank_duplicated_taxonomy_force(runtmp): assert c.last_result.status == 0 assert 'query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out - assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000.0' in c.last_result.out + assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000' in c.last_result.out def test_genome_missing_taxonomy_ignore_threshold(runtmp): @@ -1072,7 +1700,30 @@ def test_genome_missing_taxonomy_ignore_threshold(runtmp): assert c.last_result.status == 0 assert "The following are missing from the taxonomy information: GCF_001881345" in c.last_result.err assert 'query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out - assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000.0' in c.last_result.out + assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000' in c.last_result.out + + +def test_genome_missing_taxonomy_recover_with_second_tax_file(runtmp): + c = runtmp + # write temp taxonomy with missing entry + taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') + subset_csv = runtmp.output("subset_taxonomy.csv") + with open(subset_csv, 'w') as subset: + tax = [x.rstrip() for x in open(taxonomy_csv, 'r')] + tax = [tax[0]] + tax[2:] # remove the best match (1st tax entry) + subset.write("\n".join(tax)) + + g_csv = utils.get_test_data('tax/test1.gather.csv') + + c.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', subset_csv, '-t', taxonomy_csv, '--containment-threshold', '0') + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + + assert c.last_result.status == 0 + assert "The following are missing from the taxonomy information: GCF_001881345" not in c.last_result.err + assert 'query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out + assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000' in c.last_result.out def test_genome_missing_taxonomy_ignore_rank(runtmp): @@ -1095,9 +1746,10 @@ def test_genome_missing_taxonomy_ignore_rank(runtmp): assert c.last_result.status == 0 assert "The following are missing from the taxonomy information: GCF_001881345" in c.last_result.err assert 'query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out - assert 'test1,below_threshold,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000.0' in c.last_result.out + assert 'test1,below_threshold,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000' in c.last_result.out -def test_genome_missing_taxonomy_fail_threshold(runtmp): + +def test_genome_multiple_taxonomy_files(runtmp): c = runtmp # write temp taxonomy with missing entry taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') @@ -1109,23 +1761,32 @@ def test_genome_missing_taxonomy_fail_threshold(runtmp): g_csv = utils.get_test_data('tax/test1.gather.csv') - with pytest.raises(SourmashCommandFailed) as exc: - c.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', subset_csv, - '--fail-on-missing-taxonomy', '--containment-threshold', '0') + # using mult -t args + c.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', subset_csv, '-t', taxonomy_csv) + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) - print(str(exc.value)) + assert c.last_result.status == 0 + assert "The following are missing from the taxonomy information: GCF_001881345" not in c.last_result.err + assert 'query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out + assert 'test1,match,family,0.116,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae,md5,test1.sig,0.073,582000,' in c.last_result.out + # using single -t arg + c.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', subset_csv, taxonomy_csv) print(c.last_result.status) print(c.last_result.out) print(c.last_result.err) - assert "The following are missing from the taxonomy information: GCF_001881345" in str(exc.value) - assert "Failing on missing taxonomy, as requested via --fail-on-missing-taxonomy." in str(exc.value) - assert c.last_result.status == -1 + assert c.last_result.status == 0 + assert "The following are missing from the taxonomy information: GCF_001881345" not in c.last_result.err + assert 'query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out + assert 'test1,match,family,0.116,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae,md5,test1.sig,0.073,582000,' in c.last_result.out -def test_genome_missing_taxonomy_fail_rank(runtmp): +def test_genome_multiple_taxonomy_files_empty_force(runtmp): c = runtmp - # write temp taxonomy with missing entry + # write temp taxonomy with missing entry, as well as an empty file, + # and use force taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') subset_csv = runtmp.output("subset_taxonomy.csv") with open(subset_csv, 'w') as subset: @@ -1135,40 +1796,94 @@ def test_genome_missing_taxonomy_fail_rank(runtmp): g_csv = utils.get_test_data('tax/test1.gather.csv') - with pytest.raises(SourmashCommandFailed) as exc: - c.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', subset_csv, - '--fail-on-missing-taxonomy', '--rank', 'species') - - print(str(exc.value)) + empty_tax = runtmp.output('tax_empty.txt') + with open(empty_tax, "w") as fp: + fp.write("") + + # using mult -t args + c.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', subset_csv, '-t', taxonomy_csv, '-t', empty_tax, '--force') print(c.last_result.status) print(c.last_result.out) print(c.last_result.err) - assert "The following are missing from the taxonomy information: GCF_001881345" in str(exc.value) - assert "Failing on missing taxonomy, as requested via --fail-on-missing-taxonomy." in str(exc.value) - assert c.last_result.status == -1 + assert c.last_result.status == 0 + assert "The following are missing from the taxonomy information: GCF_001881345" not in c.last_result.err + assert 'query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out + assert 'test1,match,family,0.116,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae,md5,test1.sig,0.073,582000,' in c.last_result.out -def test_genome_rank_not_available(runtmp): +def test_genome_missing_taxonomy_fail_threshold(runtmp): c = runtmp + # write temp taxonomy with missing entry + taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') + subset_csv = runtmp.output("subset_taxonomy.csv") + with open(subset_csv, 'w') as subset: + tax = [x.rstrip() for x in open(taxonomy_csv, 'r')] + tax = [tax[0]] + tax[2:] # remove the best match (1st tax entry) + subset.write("\n".join(tax)) g_csv = utils.get_test_data('tax/test1.gather.csv') - tax = utils.get_test_data('tax/test.taxonomy.csv') with pytest.raises(SourmashCommandFailed) as exc: - c.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', tax, - '--rank', 'strain', '--containment-threshold', '0') + c.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', subset_csv, + '--fail-on-missing-taxonomy', '--containment-threshold', '0') print(str(exc.value)) print(c.last_result.status) print(c.last_result.out) print(c.last_result.err) + assert "ident 'GCF_001881345' is not in the taxonomy database." in str(exc.value) + assert "Failing, as requested via --fail-on-missing-taxonomy" in str(exc.value) assert c.last_result.status == -1 - assert "No taxonomic information provided for rank strain: cannot classify at this rank" in str(exc.value) -def test_genome_empty_gather_results_with_header_single(runtmp): +def test_genome_missing_taxonomy_fail_rank(runtmp): + c = runtmp + # write temp taxonomy with missing entry + taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') + subset_csv = runtmp.output("subset_taxonomy.csv") + with open(subset_csv, 'w') as subset: + tax = [x.rstrip() for x in open(taxonomy_csv, 'r')] + tax = [tax[0]] + tax[2:] # remove the best match (1st tax entry) + subset.write("\n".join(tax)) + + g_csv = utils.get_test_data('tax/test1.gather.csv') + + with pytest.raises(SourmashCommandFailed) as exc: + c.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', subset_csv, + '--fail-on-missing-taxonomy', '--rank', 'species') + + print(str(exc.value)) + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + + assert "ident 'GCF_001881345' is not in the taxonomy database." in str(exc.value) + assert "Failing, as requested via --fail-on-missing-taxonomy" in str(exc.value) + assert c.last_result.status == -1 + + +def test_genome_rank_not_available(runtmp): + c = runtmp + + g_csv = utils.get_test_data('tax/test1.gather.csv') + tax = utils.get_test_data('tax/test.taxonomy.csv') + + with pytest.raises(SourmashCommandFailed) as exc: + c.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', tax, + '--rank', 'strain', '--containment-threshold', '0') + + print(str(exc.value)) + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + + assert c.last_result.status == -1 + assert "No taxonomic information provided for rank strain: cannot classify at this rank" in str(exc.value) + + +def test_genome_empty_gather_results_with_header_single(runtmp): c = runtmp taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') @@ -1210,7 +1925,7 @@ def test_genome_empty_gather_results_single(runtmp): print(c.last_result.err) assert c.last_result.status == -1 - assert f'Cannot read gather results from {empty_tax}. Is file empty?' in str(exc.value) + assert f"Cannot read gather results from '{empty_tax}'. Is file empty?" in str(exc.value) assert 'Exiting.' in c.last_result.err @@ -1288,9 +2003,9 @@ def test_genome_empty_gather_results_with_csv_force(runtmp): assert c.last_result.status == 0 assert '--force is set. Attempting to continue to next set of gather results.' in c.last_result.err - assert 'loaded results from 1 gather CSVs' in c.last_result.err + assert 'loaded results for 1 queries from 1 gather CSVs' in c.last_result.err assert 'query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out - assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000.0' in c.last_result.out + assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000' in c.last_result.out def test_genome_containment_threshold_bounds(runtmp): @@ -1363,490 +2078,1827 @@ def test_genome_over100percent_error(runtmp): print(runtmp.last_result.err) assert runtmp.last_result.status == -1 - assert "ERROR: The tax summary of query 'test1' is 1.1, which is > 100% of the query!!" in runtmp.last_result.err + assert "fraction is > 100% of the query! This should not be possible." in runtmp.last_result.err -def test_annotate_0(runtmp): - # test annotate +def test_genome_ani_threshold_input_errors(runtmp): c = runtmp - - g_csv = utils.get_test_data('tax/test1.gather.csv') + g_csv = utils.get_test_data('tax/test1.gather_old.csv') tax = utils.get_test_data('tax/test.taxonomy.csv') - csvout = runtmp.output("test1.gather.with-lineages.csv") - out_dir = os.path.dirname(csvout) + below_threshold = "-1" - c.run_sourmash('tax', 'annotate', '--gather-csv', g_csv, '--taxonomy-csv', tax, '-o', out_dir) + with pytest.raises(SourmashCommandFailed) as exc: + c.run_sourmash('tax', 'genome', '-g', tax, '--taxonomy-csv', tax, + '--ani-threshold', below_threshold) print(c.last_result.status) print(c.last_result.out) print(c.last_result.err) + assert "ERROR: Argument must be >0 and <1" in str(exc.value) - assert c.last_result.status == 0 + above_threshold = "1.1" + with pytest.raises(SourmashCommandFailed) as exc: + c.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', tax, + '--ani-threshold', above_threshold) - lin_gather_results = [x.rstrip() for x in open(csvout)] - print("\n".join(lin_gather_results)) - assert f"saving `annotate` output to {csvout}" in runtmp.last_result.err + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + assert "ERROR: Argument must be >0 and <1" in str(exc.value) - assert "lineage" in lin_gather_results[0] - assert "d__Bacteria;p__Proteobacteria;c__Gammaproteobacteria;o__Enterobacterales;f__Enterobacteriaceae;g__Escherichia;s__Escherichia coli" in lin_gather_results[1] - assert "d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri" in lin_gather_results[2] - assert "d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Phocaeicola;s__Phocaeicola vulgatus" in lin_gather_results[3] - assert "d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri" in lin_gather_results[4] + not_a_float = "str" + with pytest.raises(SourmashCommandFailed) as exc: + c.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', tax, + '--ani-threshold', not_a_float) + + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + assert "ERROR: Must be a floating point number" in str(exc.value) -def test_annotate_0_db(runtmp): - # test annotate with sqlite db - c = runtmp +def test_genome_ani_threshold(runtmp): + c = runtmp g_csv = utils.get_test_data('tax/test1.gather.csv') - tax = utils.get_test_data('tax/test.taxonomy.db') - csvout = runtmp.output("test1.gather.with-lineages.csv") - out_dir = os.path.dirname(csvout) + tax = utils.get_test_data('tax/test.taxonomy.csv') - c.run_sourmash('tax', 'annotate', '--gather-csv', g_csv, '--taxonomy-csv', tax, '-o', out_dir) + c.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', tax, + '--ani-threshold', "0.93") # note: I think this was previously a bug, if 0.95 produced the result below... print(c.last_result.status) print(c.last_result.out) print(c.last_result.err) assert c.last_result.status == 0 + assert 'query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out + assert 'test1,match,family,0.116,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae,md5,test1.sig,0.073,582000,0.93' in c.last_result.out - lin_gather_results = [x.rstrip() for x in open(csvout)] - print("\n".join(lin_gather_results)) - assert f"saving `annotate` output to {csvout}" in runtmp.last_result.err + # more lax threshold + c.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', tax, + '--ani-threshold', "0.9") - assert "lineage" in lin_gather_results[0] - assert "d__Bacteria;p__Proteobacteria;c__Gammaproteobacteria;o__Enterobacterales;f__Enterobacteriaceae;g__Escherichia;s__Escherichia coli" in lin_gather_results[1] - assert "d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri" in lin_gather_results[2] - assert "d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Phocaeicola;s__Phocaeicola vulgatus" in lin_gather_results[3] - assert "d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri" in lin_gather_results[4] + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + assert c.last_result.status == 0 + assert 'test1,match,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000' in c.last_result.out -def test_annotate_empty_gather_results(runtmp): - tax = utils.get_test_data('tax/test.taxonomy.csv') + # too stringent of threshold (using rank) + c.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', tax, + '--ani-threshold', "1.0", '--rank', 'species') + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + assert "test1,below_threshold,species,0.089,d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri,md5,test1.sig,0.057,444000,0.92" in c.last_result.out - #creates empty gather result - g_csv = runtmp.output('g.csv') - with open(g_csv, "w") as fp: - fp.write("") - print("g_csv: ", g_csv) + +def test_genome_ani_oldgather(runtmp): + # now fail if using gather <4.4 + c = runtmp + g_csv = utils.get_test_data('tax/test1.gather_old.csv') + tax = utils.get_test_data('tax/test.taxonomy.csv') with pytest.raises(SourmashCommandFailed) as exc: - runtmp.run_sourmash('tax', 'annotate', '-g', g_csv, '--taxonomy-csv', tax) + c.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', tax) + assert "is missing columns needed for taxonomic summarization. Please run gather with sourmash >= 4.4." in str(exc.value) + assert c.last_result.status == -1 - assert f'Cannot read gather results from {g_csv}. Is file empty?' in str(exc.value) - assert runtmp.last_result.status == -1 +def test_genome_ani_lemonade_classify(runtmp): + # test a complete MAG classification with lemonade MAG from STAMPS 2022 + # (real data!) + c = runtmp -def test_annotate_bad_gather_header(runtmp): - tax = utils.get_test_data('tax/test.taxonomy.csv') - g_csv = utils.get_test_data('tax/test1.gather.csv') + ## first run gather + genome = utils.get_test_data('tax/lemonade-MAG3.sig.gz') + matches = utils.get_test_data('tax/lemonade-MAG3.x.gtdb.matches.zip') - bad_g_csv = runtmp.output('g.csv') + c.run_sourmash('gather', genome, matches, + '--threshold-bp=5000', '-o', 'gather.csv') - #creates bad gather result - bad_g = [x.replace("query_name", "nope") for x in open(g_csv, 'r')] - with open(bad_g_csv, 'w') as fp: - for line in bad_g: - fp.write(line) - print("bad_gather_results: \n", bad_g) + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) - with pytest.raises(SourmashCommandFailed) as exc: - runtmp.run_sourmash('tax', 'annotate', '-g', bad_g_csv, '--taxonomy-csv', tax) + assert c.last_result.status == 0 - assert f'Not all required gather columns are present in {bad_g_csv}.' in str(exc.value) - assert runtmp.last_result.status == -1 + this_gather_file = c.output('gather.csv') + this_gather = open(this_gather_file).readlines() + assert len(this_gather) == 4 -def test_annotate_empty_tax_lineage_input(runtmp): - tax_empty = runtmp.output('t.csv') - g_csv = utils.get_test_data('tax/test1.gather.csv') + ## now run 'tax genome' with human output + taxonomy_file = utils.get_test_data('tax/lemonade-MAG3.x.gtdb.matches.tax.csv') + c.run_sourmash('tax', 'genome', '-g', this_gather_file, '-t', taxonomy_file, + '--ani', '0.8', '-F', 'human') - with open(tax_empty, "w") as fp: - fp.write("") - print("t_csv: ", tax_empty) + output = c.last_result.out + assert 'MAG3_1 match 5.3% 91.0% d__Bacteria;p__Bacteroidota;c__Chlorobia;o__Chlorobiales;f__Chlorobiaceae;g__Prosthecochloris;s__Prosthecochloris vibrioformis' in output + + # aaand classify to lineage_csv + c.run_sourmash('tax', 'genome', '-g', this_gather_file, '-t', taxonomy_file, + '--ani', '0.8', '-F', 'lineage_csv') + + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + output = c.last_result.out + assert 'ident,superkingdom,phylum,class,order,family,genus,species' in output + assert 'MAG3_1,d__Bacteria,p__Bacteroidota,c__Chlorobia,o__Chlorobiales,f__Chlorobiaceae,g__Prosthecochloris,s__Prosthecochloris vibrioformis' in output +def test_metagenome_no_gather_csv(runtmp): + # test tax metagenome with no -g + taxonomy_file = utils.get_test_data('tax/lemonade-MAG3.x.gtdb.matches.tax.csv') with pytest.raises(SourmashCommandFailed) as exc: - runtmp.run_sourmash('tax', 'annotate', '-g', g_csv, '--taxonomy-csv', tax_empty) + runtmp.run_sourmash('tax', 'metagenome', '-t', taxonomy_file) print(runtmp.last_result.status) print(runtmp.last_result.out) print(runtmp.last_result.err) - assert runtmp.last_result.status != 0 - assert "cannot read taxonomy assignments from" in str(exc.value) +def test_genome_no_gather_csv(runtmp): + # test tax genome with no -g + taxonomy_file = utils.get_test_data('tax/lemonade-MAG3.x.gtdb.matches.tax.csv') + with pytest.raises(SourmashCommandFailed) as exc: + runtmp.run_sourmash('tax', 'genome', '-t', taxonomy_file) -def test_tax_prepare_1_csv_to_csv(runtmp, keep_identifiers, keep_versions): - # CSV -> CSV; same assignments - tax = utils.get_test_data('tax/test.taxonomy.csv') - taxout = runtmp.output('out.csv') + print(runtmp.last_result.status) + print(runtmp.last_result.out) + print(runtmp.last_result.err) - args = [] - if keep_identifiers: - args.append('--keep-full-identifiers') - if keep_versions: - args.append('--keep-identifier-versions') - # this is an error - can't strip versions if not splitting identifiers - if keep_identifiers and not keep_versions: - with pytest.raises(SourmashCommandFailed): - runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', - taxout, '-F', 'csv', *args) - return +def test_annotate_no_gather_csv(runtmp): + # test tax annotate with no -g + taxonomy_file = utils.get_test_data('tax/lemonade-MAG3.x.gtdb.matches.tax.csv') + with pytest.raises(SourmashCommandFailed) as exc: + runtmp.run_sourmash('tax', 'annotate', '-t', taxonomy_file) - runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', - taxout, '-F', 'csv', *args) - assert os.path.exists(taxout) + print(runtmp.last_result.status) + print(runtmp.last_result.out) + print(runtmp.last_result.err) - db1 = tax_utils.MultiLineageDB.load([tax], - keep_full_identifiers=keep_identifiers, - keep_identifier_versions=keep_versions) - db2 = tax_utils.MultiLineageDB.load([taxout]) +def test_genome_LIN(runtmp): + # test basic genome with LIN taxonomy + c = runtmp - assert set(db1) == set(db2) + g_csv = utils.get_test_data('tax/test1.gather.csv') + tax = utils.get_test_data('tax/test.LIN-taxonomy.csv') + c.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', tax, '--lins', '--ani-threshold', '0.93') -def test_tax_prepare_1_csv_to_csv_empty_ranks(runtmp, keep_identifiers, keep_versions): - # CSV -> CSV; same assignments, even when trailing ranks are empty - tax = utils.get_test_data('tax/test-empty-ranks.taxonomy.csv') - taxout = runtmp.output('out.csv') + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) - args = [] - if keep_identifiers: - args.append('--keep-full-identifiers') - if keep_versions: - args.append('--keep-identifier-versions') + assert c.last_result.status == 0 + assert "query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank,query_ani_at_rank" in c.last_result.out + assert "test1,below_threshold,0,0.089,1,md5,test1.sig,0.057,444000,0.925" in c.last_result.out - # this is an error - can't strip versions if not splitting identifiers - if keep_identifiers and not keep_versions: - with pytest.raises(SourmashCommandFailed): - runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', - taxout, '-F', 'csv', *args) - return + c.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', tax, '--lins', '--ani-threshold', '0.924') - runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', - taxout, '-F', 'csv', *args) - assert os.path.exists(taxout) + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) - db1 = tax_utils.MultiLineageDB.load([tax], - keep_full_identifiers=keep_identifiers, - keep_identifier_versions=keep_versions) + assert c.last_result.status == 0 + assert "query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank,query_ani_at_rank" in c.last_result.out + assert "test1,match,19,0.088,0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0,md5,test1.sig,0.058,442000,0.925" in c.last_result.out - db2 = tax_utils.MultiLineageDB.load([taxout]) + c.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', tax, '--lins', '--rank', '4') - assert set(db1) == set(db2) + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + assert c.last_result.status == 0 + assert "query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank,query_ani_at_rank" in c.last_result.out + assert "test1,below_threshold,4,0.088,0;0;0;0;0,md5,test1.sig,0.058,442000,0.925" in c.last_result.out -def test_tax_prepare_1_csv_to_csv_empty_ranks_2(runtmp, keep_identifiers, keep_versions): - # CSV -> CSV; same assignments for situations with empty internal ranks - tax = utils.get_test_data('tax/test-empty-ranks-2.taxonomy.csv') - taxout = runtmp.output('out.csv') - args = [] - if keep_identifiers: - args.append('--keep-full-identifiers') - if keep_versions: - args.append('--keep-identifier-versions') +def test_genome_LIN_lingroups(runtmp): + # test basic genome with LIN taxonomy + c = runtmp - # this is an error - can't strip versions if not splitting identifiers - if keep_identifiers and not keep_versions: - with pytest.raises(SourmashCommandFailed): - runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', - taxout, '-F', 'csv', *args) - return + g_csv = utils.get_test_data('tax/test1.gather.csv') + tax = utils.get_test_data('tax/test.LIN-taxonomy.csv') - runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', - taxout, '-F', 'csv', *args) - assert os.path.exists(taxout) + lg_file = runtmp.output("test.lg.csv") - db1 = tax_utils.MultiLineageDB.load([tax], - keep_full_identifiers=keep_identifiers, - keep_identifier_versions=keep_versions) + with open(lg_file, 'w') as out: + out.write('lin,name\n') + out.write('0;0;0,lg1\n') + out.write('1;0;0,lg2\n') + out.write('2;0;0,lg3\n') + out.write('1;0;1,lg3\n') + # write a 19 so we can check the end + out.write('0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0,lg4\n') - db2 = tax_utils.MultiLineageDB.load([taxout]) + c.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', tax, '--lins', '--lingroup', lg_file) - assert set(db1) == set(db2) + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + assert c.last_result.status == 0 + assert "query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank,query_ani_at_rank" in c.last_result.out + assert "test1,below_threshold,2,0.088,0;0;0,md5,test1.sig,0.058,442000,0.925" in c.last_result.out -def test_tax_prepare_1_csv_to_csv_empty_ranks_3(runtmp, keep_identifiers, keep_versions): - # CSV -> CSV; same assignments for situations with empty internal ranks - tax = utils.get_test_data('tax/test-empty-ranks-3.taxonomy.csv') - taxout = runtmp.output('out.csv') + c.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', tax, '--lins', '--lingroup', lg_file, '--ani-threshold', '0.924') - args = [] - if keep_identifiers: - args.append('--keep-full-identifiers') - if keep_versions: - args.append('--keep-identifier-versions') + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) - # this is an error - can't strip versions if not splitting identifiers - if keep_identifiers and not keep_versions: + assert c.last_result.status == 0 + assert "query_name,status,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank,query_ani_at_rank" in c.last_result.out + assert "test1,match,19,0.088,0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0,md5,test1.sig,0.058,442000,0.925" in c.last_result.out + + +def test_annotate_0(runtmp): + # test annotate basics + c = runtmp + + g_csv = utils.get_test_data('tax/test1.gather.csv') + tax = utils.get_test_data('tax/test.taxonomy.csv') + csvout = runtmp.output("test1.gather.with-lineages.csv") + out_dir = os.path.dirname(csvout) + + c.run_sourmash('tax', 'annotate', '--gather-csv', g_csv, '--taxonomy-csv', tax, '-o', out_dir) + + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + + assert c.last_result.status == 0 + assert os.path.exists(csvout) + + lin_gather_results = [x.rstrip() for x in open(csvout)] + print("\n".join(lin_gather_results)) + assert f"saving 'annotate' output to '{csvout}'" in runtmp.last_result.err + + assert "lineage" in lin_gather_results[0] + assert "d__Bacteria;p__Proteobacteria;c__Gammaproteobacteria;o__Enterobacterales;f__Enterobacteriaceae;g__Escherichia;s__Escherichia coli" in lin_gather_results[1] + assert "d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri" in lin_gather_results[2] + assert "d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Phocaeicola;s__Phocaeicola vulgatus" in lin_gather_results[3] + assert "d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri" in lin_gather_results[4] + + +def test_annotate_gzipped_gather(runtmp): + # test annotate basics + c = runtmp + + g_csv = utils.get_test_data('tax/test1.gather.csv') + # rewrite gather_csv as gzipped csv + gz_gather = runtmp.output('test1.gather.csv.gz') + with open(g_csv, 'rb') as f_in, gzip.open(gz_gather, 'wb') as f_out: + f_out.writelines(f_in) + + tax = utils.get_test_data('tax/test.taxonomy.csv') + csvout = runtmp.output("test1.gather.with-lineages.csv") + out_dir = os.path.dirname(csvout) + + c.run_sourmash('tax', 'annotate', '--gather-csv', gz_gather, '--taxonomy-csv', tax, '-o', out_dir) + + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + + assert c.last_result.status == 0 + assert os.path.exists(csvout) + + lin_gather_results = [x.rstrip() for x in open(csvout)] + print("\n".join(lin_gather_results)) + assert f"saving 'annotate' output to '{csvout}'" in runtmp.last_result.err + + assert "lineage" in lin_gather_results[0] + assert "d__Bacteria;p__Proteobacteria;c__Gammaproteobacteria;o__Enterobacterales;f__Enterobacteriaceae;g__Escherichia;s__Escherichia coli" in lin_gather_results[1] + assert "d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri" in lin_gather_results[2] + assert "d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Phocaeicola;s__Phocaeicola vulgatus" in lin_gather_results[3] + assert "d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri" in lin_gather_results[4] + + +def test_annotate_0_LIN(runtmp): + # test annotate basics + c = runtmp + + g_csv = utils.get_test_data('tax/test1.gather.csv') + tax = utils.get_test_data('tax/test.LIN-taxonomy.csv') + csvout = runtmp.output("test1.gather.with-lineages.csv") + out_dir = os.path.dirname(csvout) + + c.run_sourmash('tax', 'annotate', '--gather-csv', g_csv, '--taxonomy-csv', tax, '-o', out_dir, "--lins") + + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + + assert c.last_result.status == 0 + assert os.path.exists(csvout) + + lin_gather_results = [x.rstrip() for x in open(csvout)] + print("\n".join(lin_gather_results)) + assert f"saving 'annotate' output to '{csvout}'" in runtmp.last_result.err + + assert "lineage" in lin_gather_results[0] + assert "0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0" in lin_gather_results[1] + assert "1;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0" in lin_gather_results[2] + assert "2;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0" in lin_gather_results[3] + assert "1;0;1;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0" in lin_gather_results[4] + + +def test_annotate_gather_argparse(runtmp): + # test annotate with two gather CSVs, second one empty, and --force. + # this tests argparse handling w/extend. + c = runtmp + + g_csv = utils.get_test_data('tax/test1.gather.csv') + tax = utils.get_test_data('tax/test.taxonomy.csv') + csvout = runtmp.output("test1.gather.with-lineages.csv") + out_dir = os.path.dirname(csvout) + + g_empty_csv = runtmp.output('g_empty.csv') + with open(g_empty_csv, "w") as fp: + fp.write("") + print("g_csv: ", g_empty_csv) + + c.run_sourmash('tax', 'annotate', '--gather-csv', g_csv, + '-g', g_empty_csv, '--taxonomy-csv', tax, '-o', out_dir, + '--force') + + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + + assert c.last_result.status == 0 + assert os.path.exists(csvout) + + lin_gather_results = [x.rstrip() for x in open(csvout)] + print("\n".join(lin_gather_results)) + assert f"saving 'annotate' output to '{csvout}'" in runtmp.last_result.err + + assert "lineage" in lin_gather_results[0] + assert "d__Bacteria;p__Proteobacteria;c__Gammaproteobacteria;o__Enterobacterales;f__Enterobacteriaceae;g__Escherichia;s__Escherichia coli" in lin_gather_results[1] + + +def test_annotate_0_db(runtmp): + # test annotate with sqlite db + c = runtmp + + g_csv = utils.get_test_data('tax/test1.gather.csv') + tax = utils.get_test_data('tax/test.taxonomy.db') + csvout = runtmp.output("test1.gather.with-lineages.csv") + out_dir = os.path.dirname(csvout) + + c.run_sourmash('tax', 'annotate', '--gather-csv', g_csv, '--taxonomy-csv', tax, '-o', out_dir) + + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + + assert c.last_result.status == 0 + + lin_gather_results = [x.rstrip() for x in open(csvout)] + print("\n".join(lin_gather_results)) + assert f"saving 'annotate' output to '{csvout}'" in runtmp.last_result.err + + assert "lineage" in lin_gather_results[0] + assert "d__Bacteria;p__Proteobacteria;c__Gammaproteobacteria;o__Enterobacterales;f__Enterobacteriaceae;g__Escherichia;s__Escherichia coli" in lin_gather_results[1] + assert "d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri" in lin_gather_results[2] + assert "d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Phocaeicola;s__Phocaeicola vulgatus" in lin_gather_results[3] + assert "d__Bacteria;p__Bacteroidota;c__Bacteroidia;o__Bacteroidales;f__Bacteroidaceae;g__Prevotella;s__Prevotella copri" in lin_gather_results[4] + + +def test_annotate_empty_gather_results(runtmp): + tax = utils.get_test_data('tax/test.taxonomy.csv') + + #creates empty gather result + g_csv = runtmp.output('g.csv') + with open(g_csv, "w") as fp: + fp.write("") + print("g_csv: ", g_csv) + + with pytest.raises(SourmashCommandFailed) as exc: + runtmp.run_sourmash('tax', 'annotate', '-g', g_csv, '--taxonomy-csv', tax) + + assert f"Cannot read from '{g_csv}'. Is file empty?" in str(exc.value) + assert runtmp.last_result.status == -1 + + +def test_annotate_prefetch_or_other_header(runtmp): + tax = utils.get_test_data('tax/test.taxonomy.csv') + g_csv = utils.get_test_data('tax/test1.gather.csv') + + alt_csv = runtmp.output('g.csv') + for alt_col in ['match_name', 'ident', 'accession']: + #modify 'name' to other acceptable id_columns result + alt_g = [x.replace("name", alt_col) for x in open(g_csv, 'r')] + with open(alt_csv, 'w') as fp: + for line in alt_g: + fp.write(line) + + runtmp.run_sourmash('tax', 'annotate', '-g', alt_csv, '--taxonomy-csv', tax) + + assert runtmp.last_result.status == 0 + print(runtmp.last_result.out) + print(runtmp.last_result.err) + assert f"Starting annotation on '{alt_csv}'. Using ID column: '{alt_col}'" in runtmp.last_result.err + assert f"Annotated 4 of 4 total rows from '{alt_csv}'" in runtmp.last_result.err + + +def test_annotate_bad_header(runtmp): + tax = utils.get_test_data('tax/test.taxonomy.csv') + g_csv = utils.get_test_data('tax/test1.gather.csv') + + bad_g_csv = runtmp.output('g.csv') + + #creates bad gather result + bad_g = [x.replace("name", "nope") for x in open(g_csv, 'r')] + with open(bad_g_csv, 'w') as fp: + for line in bad_g: + fp.write(line) + # print("bad_gather_results: \n", bad_g) + + with pytest.raises(SourmashCommandFailed) as exc: + runtmp.run_sourmash('tax', 'annotate', '-g', bad_g_csv, '--taxonomy-csv', tax) + + assert f"ERROR: Cannot find taxonomic identifier column in '{bad_g_csv}'. Tried: name, match_name, ident, accession" in str(exc.value) + assert runtmp.last_result.status == -1 + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + +def test_annotate_no_tax_matches(runtmp): + tax = utils.get_test_data('tax/test.taxonomy.csv') + g_csv = utils.get_test_data('tax/test1.gather.csv') + + bad_g_csv = runtmp.output('g.csv') + + #mess up tax idents + bad_g = [x.replace("GCF_", "GGG_") for x in open(g_csv, 'r')] + with open(bad_g_csv, 'w') as fp: + for line in bad_g: + fp.write(line) + # print("bad_gather_results: \n", bad_g) + + with pytest.raises(SourmashCommandFailed) as exc: + runtmp.run_sourmash('tax', 'annotate', '-g', bad_g_csv, '--taxonomy-csv', tax) + + assert f"ERROR: Could not annotate any rows from '{bad_g_csv}'" in str(exc.value) + assert runtmp.last_result.status == -1 + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + runtmp.run_sourmash('tax', 'annotate', '-g', bad_g_csv, '--taxonomy-csv', tax, '--force') + + assert runtmp.last_result.status == 0 + assert f"Could not annotate any rows from '{bad_g_csv}'" in runtmp.last_result.err + assert f"--force is set. Attempting to continue to next file." in runtmp.last_result.err + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + +def test_annotate_missed_tax_matches(runtmp): + tax = utils.get_test_data('tax/test.taxonomy.csv') + g_csv = utils.get_test_data('tax/test1.gather.csv') + + bad_g_csv = runtmp.output('g.csv') + + with open(g_csv, 'r') as gather_lines, open(bad_g_csv, 'w') as fp: + for n, line in enumerate(gather_lines): + if n > 2: + # mess up tax idents of lines 3, 4 + line = line.replace("GCF_", "GGG_") + fp.write(line) + # print("bad_gather_results: \n", bad_g) + + runtmp.run_sourmash('tax', 'annotate', '-g', bad_g_csv, '--taxonomy-csv', tax) + + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert runtmp.last_result.status == 0 + assert f"Annotated 2 of 4 total rows from '{bad_g_csv}'." in runtmp.last_result.err + + +def test_annotate_empty_tax_lineage_input(runtmp): + tax_empty = runtmp.output('t.csv') + g_csv = utils.get_test_data('tax/test1.gather.csv') + + with open(tax_empty, "w") as fp: + fp.write("") + print("t_csv: ", tax_empty) + + + with pytest.raises(SourmashCommandFailed) as exc: + runtmp.run_sourmash('tax', 'annotate', '-g', g_csv, '--taxonomy-csv', tax_empty) + + print(runtmp.last_result.status) + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert runtmp.last_result.status != 0 + assert "cannot read taxonomy assignments from" in str(exc.value) + + +def test_annotate_empty_tax_lineage_input_recover_with_second_taxfile(runtmp): + tax_empty = runtmp.output('t.csv') + tax = utils.get_test_data('tax/test.taxonomy.csv') + g_csv = utils.get_test_data('tax/test1.gather.csv') + + with open(tax_empty, "w") as fp: + fp.write("") + print("t_csv: ", tax_empty) + + runtmp.run_sourmash('tax', 'annotate', '-g', g_csv, '-t', tax_empty, '--taxonomy-csv', tax, '--force') + + print(runtmp.last_result.status) + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert runtmp.last_result.status == 0 + + +def test_annotate_empty_tax_lineage_input_recover_with_second_taxfile_2(runtmp): + # test with empty tax second, to check on argparse handling + tax_empty = runtmp.output('t.csv') + tax = utils.get_test_data('tax/test.taxonomy.csv') + g_csv = utils.get_test_data('tax/test1.gather.csv') + + with open(tax_empty, "w") as fp: + fp.write("") + print("t_csv: ", tax_empty) + + runtmp.run_sourmash('tax', 'annotate', '-g', g_csv, + '--taxonomy-csv', tax, '-t', tax_empty, '--force') + + print(runtmp.last_result.status) + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert runtmp.last_result.status == 0 + + +def test_tax_prepare_1_csv_to_csv(runtmp, keep_identifiers, keep_versions): + # CSV -> CSV; same assignments + tax = utils.get_test_data('tax/test.taxonomy.csv') + taxout = runtmp.output('out.csv') + + args = [] + if keep_identifiers: + args.append('--keep-full-identifiers') + if keep_versions: + args.append('--keep-identifier-versions') + + # this is an error - can't strip versions if not splitting identifiers + if keep_identifiers and not keep_versions: + with pytest.raises(SourmashCommandFailed): + runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', + taxout, '-F', 'csv', *args) + return + + runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', + taxout, '-F', 'csv', *args) + assert os.path.exists(taxout) + + db1 = tax_utils.MultiLineageDB.load([tax], + keep_full_identifiers=keep_identifiers, + keep_identifier_versions=keep_versions) + + db2 = tax_utils.MultiLineageDB.load([taxout]) + + assert set(db1) == set(db2) + + +def test_tax_prepare_1_combine_csv(runtmp): + # multiple CSVs to a single combined CSV + tax1 = utils.get_test_data('tax/test.taxonomy.csv') + tax2 = utils.get_test_data('tax/protozoa_genbank_lineage.csv') + + taxout = runtmp.output('out.csv') + + runtmp.sourmash('tax', 'prepare', '-t', tax1, tax2, '-F', 'csv', + '-o', taxout) + + out = runtmp.last_result.out + err = runtmp.last_result.err + + assert not out + assert "...loaded 8 entries" in err + + out = open(taxout).readlines() + assert len(out) == 9 + + +def test_tax_prepare_1_csv_to_csv_empty_ranks(runtmp, keep_identifiers, keep_versions): + # CSV -> CSV; same assignments, even when trailing ranks are empty + tax = utils.get_test_data('tax/test-empty-ranks.taxonomy.csv') + taxout = runtmp.output('out.csv') + + args = [] + if keep_identifiers: + args.append('--keep-full-identifiers') + if keep_versions: + args.append('--keep-identifier-versions') + + # this is an error - can't strip versions if not splitting identifiers + if keep_identifiers and not keep_versions: + with pytest.raises(SourmashCommandFailed): + runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', + taxout, '-F', 'csv', *args) + return + + runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', + taxout, '-F', 'csv', *args) + assert os.path.exists(taxout) + + db1 = tax_utils.MultiLineageDB.load([tax], + keep_full_identifiers=keep_identifiers, + keep_identifier_versions=keep_versions) + + db2 = tax_utils.MultiLineageDB.load([taxout]) + + assert set(db1) == set(db2) + + +def test_tax_prepare_1_csv_to_csv_empty_file(runtmp, keep_identifiers, keep_versions): + # CSV -> CSV with an empty input file and --force + # tests argparse extend + tax = utils.get_test_data('tax/test-empty-ranks.taxonomy.csv') + tax_empty = runtmp.output('t.csv') + taxout = runtmp.output('out.csv') + + with open(tax_empty, "w") as fp: + fp.write("") + print("t_csv: ", tax_empty) + + args = [] + if keep_identifiers: + args.append('--keep-full-identifiers') + if keep_versions: + args.append('--keep-identifier-versions') + + # this is an error - can't strip versions if not splitting identifiers + if keep_identifiers and not keep_versions: + with pytest.raises(SourmashCommandFailed): + runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', + taxout, '-F', 'csv', *args) + return + + runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-t', tax_empty, '-o', + taxout, '-F', 'csv', *args, '--force') + assert os.path.exists(taxout) + + db1 = tax_utils.MultiLineageDB.load([tax], + keep_full_identifiers=keep_identifiers, + keep_identifier_versions=keep_versions) + + db2 = tax_utils.MultiLineageDB.load([taxout]) + + assert set(db1) == set(db2) + + +def test_tax_prepare_1_csv_to_csv_empty_ranks_2(runtmp, keep_identifiers, keep_versions): + # CSV -> CSV; same assignments for situations with empty internal ranks + tax = utils.get_test_data('tax/test-empty-ranks-2.taxonomy.csv') + taxout = runtmp.output('out.csv') + + args = [] + if keep_identifiers: + args.append('--keep-full-identifiers') + if keep_versions: + args.append('--keep-identifier-versions') + + # this is an error - can't strip versions if not splitting identifiers + if keep_identifiers and not keep_versions: + with pytest.raises(SourmashCommandFailed): + runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', + taxout, '-F', 'csv', *args) + return + + runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', + taxout, '-F', 'csv', *args) + assert os.path.exists(taxout) + + db1 = tax_utils.MultiLineageDB.load([tax], + keep_full_identifiers=keep_identifiers, + keep_identifier_versions=keep_versions) + + db2 = tax_utils.MultiLineageDB.load([taxout]) + + assert set(db1) == set(db2) + + +def test_tax_prepare_1_csv_to_csv_empty_ranks_3(runtmp, keep_identifiers, keep_versions): + # CSV -> CSV; same assignments for situations with empty internal ranks + tax = utils.get_test_data('tax/test-empty-ranks-3.taxonomy.csv') + taxout = runtmp.output('out.csv') + + args = [] + if keep_identifiers: + args.append('--keep-full-identifiers') + if keep_versions: + args.append('--keep-identifier-versions') + + # this is an error - can't strip versions if not splitting identifiers + if keep_identifiers and not keep_versions: with pytest.raises(SourmashCommandFailed): runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', taxout, '-F', 'csv', *args) return - runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', - taxout, '-F', 'csv', *args) + runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', + taxout, '-F', 'csv', *args) + assert os.path.exists(taxout) + + db1 = tax_utils.MultiLineageDB.load([tax], + keep_full_identifiers=keep_identifiers, + keep_identifier_versions=keep_versions) + + db2 = tax_utils.MultiLineageDB.load([taxout]) + + assert set(db1) == set(db2) + + +def test_tax_prepare_2_csv_to_sql(runtmp, keep_identifiers, keep_versions): + # CSV -> SQL; same assignments? + tax = utils.get_test_data('tax/test.taxonomy.csv') + taxout = runtmp.output('out.db') + + args = [] + if keep_identifiers: + args.append('--keep-full-identifiers') + if keep_versions: + args.append('--keep-identifier-versions') + + # this is an error - can't strip versions if not splitting identifiers + if keep_identifiers and not keep_versions: + with pytest.raises(SourmashCommandFailed): + runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', taxout, + '-F', 'sql', *args) + return + + runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', taxout, + '-F', 'sql', *args) + assert os.path.exists(taxout) + + db1 = tax_utils.MultiLineageDB.load([tax], + keep_full_identifiers=keep_identifiers, + keep_identifier_versions=keep_versions) + db2 = tax_utils.MultiLineageDB.load([taxout]) + + assert set(db1) == set(db2) + + # cannot overwrite - + with pytest.raises(SourmashCommandFailed) as exc: + runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', taxout, + '-F', 'sql', *args) + assert 'taxonomy table already exists' in str(exc.value) + + +def test_tax_prepare_2_csv_to_sql_empty_ranks(runtmp, keep_identifiers, keep_versions): + # CSV -> SQL with some empty ranks in the taxonomy file + tax = utils.get_test_data('tax/test-empty-ranks.taxonomy.csv') + taxout = runtmp.output('out.db') + + args = [] + if keep_identifiers: + args.append('--keep-full-identifiers') + if keep_versions: + args.append('--keep-identifier-versions') + + # this is an error - can't strip versions if not splitting identifiers + if keep_identifiers and not keep_versions: + with pytest.raises(SourmashCommandFailed): + runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', taxout, + '-F', 'sql', *args) + return + + runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', taxout, + '-F', 'sql', *args) + assert os.path.exists(taxout) + + db1 = tax_utils.MultiLineageDB.load([tax], + keep_full_identifiers=keep_identifiers, + keep_identifier_versions=keep_versions) + db2 = tax_utils.MultiLineageDB.load([taxout]) + + assert set(db1) == set(db2) + + +def test_tax_prepare_3_db_to_csv(runtmp): + # SQL -> CSV; same assignments + taxcsv = utils.get_test_data('tax/test.taxonomy.csv') + taxdb = utils.get_test_data('tax/test.taxonomy.db') + taxout = runtmp.output('out.csv') + + runtmp.run_sourmash('tax', 'prepare', '-t', taxdb, + '-o', taxout, '-F', 'csv') + assert os.path.exists(taxout) + with open(taxout) as fp: + print(fp.read()) + + db1 = tax_utils.MultiLineageDB.load([taxcsv], + keep_full_identifiers=False, + keep_identifier_versions=False) + + db2 = tax_utils.MultiLineageDB.load([taxout]) + db3 = tax_utils.MultiLineageDB.load([taxdb], + keep_full_identifiers=False, + keep_identifier_versions=False) + assert set(db1) == set(db2) + assert set(db1) == set(db3) + + +def test_tax_prepare_3_db_to_csv_gz(runtmp): + # SQL -> CSV; same assignments + taxcsv = utils.get_test_data('tax/test.taxonomy.csv') + taxdb = utils.get_test_data('tax/test.taxonomy.db') + taxout = runtmp.output('out.csv.gz') + + runtmp.run_sourmash('tax', 'prepare', '-t', taxdb, + '-o', taxout, '-F', 'csv') + assert os.path.exists(taxout) + with gzip.open(taxout, 'rt') as fp: + print(fp.read()) + + db1 = tax_utils.MultiLineageDB.load([taxcsv], + keep_full_identifiers=False, + keep_identifier_versions=False) + + db2 = tax_utils.MultiLineageDB.load([taxout]) + db3 = tax_utils.MultiLineageDB.load([taxdb], + keep_full_identifiers=False, + keep_identifier_versions=False) + assert set(db1) == set(db2) + assert set(db1) == set(db3) + + +def test_tax_prepare_2_csv_to_sql_empty_ranks_2(runtmp, keep_identifiers, keep_versions): + # CSV -> SQL with some empty internal ranks in the taxonomy file + tax = utils.get_test_data('tax/test-empty-ranks-2.taxonomy.csv') + taxout = runtmp.output('out.db') + + args = [] + if keep_identifiers: + args.append('--keep-full-identifiers') + if keep_versions: + args.append('--keep-identifier-versions') + + # this is an error - can't strip versions if not splitting identifiers + if keep_identifiers and not keep_versions: + with pytest.raises(SourmashCommandFailed): + runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', taxout, + '-F', 'sql', *args) + return + + runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', taxout, + '-F', 'sql', *args) + assert os.path.exists(taxout) + + db1 = tax_utils.MultiLineageDB.load([tax], + keep_full_identifiers=keep_identifiers, + keep_identifier_versions=keep_versions) + db2 = tax_utils.MultiLineageDB.load([taxout]) + + assert set(db1) == set(db2) + + +def test_tax_prepare_2_csv_to_sql_empty_ranks_3(runtmp, keep_identifiers, keep_versions): + # CSV -> SQL with some empty internal ranks in the taxonomy file + tax = utils.get_test_data('tax/test-empty-ranks-3.taxonomy.csv') + taxout = runtmp.output('out.db') + + args = [] + if keep_identifiers: + args.append('--keep-full-identifiers') + if keep_versions: + args.append('--keep-identifier-versions') + + # this is an error - can't strip versions if not splitting identifiers + if keep_identifiers and not keep_versions: + with pytest.raises(SourmashCommandFailed): + runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', taxout, + '-F', 'sql', *args) + return + + runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', taxout, + '-F', 'sql', *args) + assert os.path.exists(taxout) + + db1 = tax_utils.MultiLineageDB.load([tax], + keep_full_identifiers=keep_identifiers, + keep_identifier_versions=keep_versions) + db2 = tax_utils.MultiLineageDB.load([taxout]) + + assert set(db1) == set(db2) + + +def test_tax_prepare_3_db_to_csv_empty_ranks(runtmp): + # SQL -> CSV; same assignments, with empty ranks + taxcsv = utils.get_test_data('tax/test-empty-ranks.taxonomy.csv') + taxdb = utils.get_test_data('tax/test-empty-ranks.taxonomy.db') + taxout = runtmp.output('out.csv') + + runtmp.run_sourmash('tax', 'prepare', '-t', taxdb, + '-o', taxout, '-F', 'csv') + assert os.path.exists(taxout) + with open(taxout) as fp: + print(fp.read()) + + db1 = tax_utils.MultiLineageDB.load([taxcsv], + keep_full_identifiers=False, + keep_identifier_versions=False) + + db2 = tax_utils.MultiLineageDB.load([taxout]) + db3 = tax_utils.MultiLineageDB.load([taxdb], + keep_full_identifiers=False, + keep_identifier_versions=False) + assert set(db1) == set(db2) + assert set(db1) == set(db3) + + +def test_tax_prepare_3_db_to_csv_empty_ranks_2(runtmp): + # SQL -> CSV; same assignments, with empty ranks + taxcsv = utils.get_test_data('tax/test-empty-ranks-2.taxonomy.csv') + taxdb = utils.get_test_data('tax/test-empty-ranks-2.taxonomy.db') + taxout = runtmp.output('out.csv') + + runtmp.run_sourmash('tax', 'prepare', '-t', taxdb, + '-o', taxout, '-F', 'csv') + assert os.path.exists(taxout) + with open(taxout) as fp: + print(fp.read()) + + db1 = tax_utils.MultiLineageDB.load([taxcsv], + keep_full_identifiers=False, + keep_identifier_versions=False) + + db2 = tax_utils.MultiLineageDB.load([taxout]) + db3 = tax_utils.MultiLineageDB.load([taxdb], + keep_full_identifiers=False, + keep_identifier_versions=False) + assert set(db1) == set(db2) + assert set(db1) == set(db3) + + +def test_tax_prepare_3_db_to_csv_empty_ranks_3(runtmp): + # SQL -> CSV; same assignments, with empty ranks + taxcsv = utils.get_test_data('tax/test-empty-ranks-3.taxonomy.csv') + taxdb = utils.get_test_data('tax/test-empty-ranks-3.taxonomy.db') + taxout = runtmp.output('out.csv') + + runtmp.run_sourmash('tax', 'prepare', '-t', taxdb, + '-o', taxout, '-F', 'csv') + assert os.path.exists(taxout) + with open(taxout) as fp: + print(fp.read()) + + db1 = tax_utils.MultiLineageDB.load([taxcsv], + keep_full_identifiers=False, + keep_identifier_versions=False) + + db2 = tax_utils.MultiLineageDB.load([taxout]) + db3 = tax_utils.MultiLineageDB.load([taxdb], + keep_full_identifiers=False, + keep_identifier_versions=False) + assert set(db1) == set(db2) + assert set(db1) == set(db3) + + +def test_tax_prepare_sqlite_lineage_version(runtmp): + # test bad sourmash_internals version for SqliteLineage + taxcsv = utils.get_test_data('tax/test.taxonomy.csv') + taxout = runtmp.output('out.db') + + runtmp.run_sourmash('tax', 'prepare', '-t', taxcsv, + '-o', taxout, '-F', 'sql') assert os.path.exists(taxout) - db1 = tax_utils.MultiLineageDB.load([tax], - keep_full_identifiers=keep_identifiers, - keep_identifier_versions=keep_versions) + # set bad version + conn = sqlite_utils.open_sqlite_db(taxout) + c = conn.cursor() + c.execute("UPDATE sourmash_internal SET value='0.9' WHERE key='SqliteLineage'") + + conn.commit() + conn.close() + + with pytest.raises(IndexNotSupported): + db = tax_utils.MultiLineageDB.load([taxout]) + + +def test_tax_prepare_sqlite_no_lineage(): + # no lineage table at all + sqldb = utils.get_test_data('sqlite/index.sqldb') + + with pytest.raises(ValueError): + db = tax_utils.MultiLineageDB.load([sqldb]) + + +def test_tax_grep_exists(runtmp): + # test that 'tax grep' exists + + with pytest.raises(SourmashCommandFailed): + runtmp.sourmash('tax', 'grep') + + err = runtmp.last_result.err + assert 'usage:' in err + + +def test_tax_grep_search_shew(runtmp): + # test 'tax grep Shew' + taxfile = utils.get_test_data('tax/test.taxonomy.csv') + + runtmp.sourmash('tax', 'grep', 'Shew', '-t', taxfile) + + out = runtmp.last_result.out + err = runtmp.last_result.err + + lines = [ x.strip() for x in out.splitlines() ] + lines = [ x.split(',') for x in lines ] + assert lines[0][0] == 'ident' + assert lines[1][0] == 'GCF_000017325.1' + assert lines[2][0] == 'GCF_000021665.1' + assert len(lines) == 3 + + assert "searching 1 taxonomy files for 'Shew'" in err + assert 'found 2 matches; saved identifiers to picklist' in err + + +def test_tax_grep_search_shew_out(runtmp): + # test 'tax grep Shew', save result to a file + taxfile = utils.get_test_data('tax/test.taxonomy.csv') + + runtmp.sourmash('tax', 'grep', 'Shew', '-t', taxfile, '-o', 'pick.csv') + + err = runtmp.last_result.err + + out = open(runtmp.output('pick.csv')).read() + lines = [ x.strip() for x in out.splitlines() ] + lines = [ x.split(',') for x in lines ] + assert lines[0][0] == 'ident' + assert lines[1][0] == 'GCF_000017325.1' + assert lines[2][0] == 'GCF_000021665.1' + assert len(lines) == 3 + + assert "searching 1 taxonomy files for 'Shew'" in err + assert 'found 2 matches; saved identifiers to picklist' in err + + +def test_tax_grep_search_shew_sqldb_out(runtmp): + # test 'tax grep Shew' on a sqldb, save result to a file + taxfile = utils.get_test_data('tax/test.taxonomy.db') + + runtmp.sourmash('tax', 'grep', 'Shew', '-t', taxfile, '-o', 'pick.csv') + + err = runtmp.last_result.err + + out = open(runtmp.output('pick.csv')).read() + lines = [ x.strip() for x in out.splitlines() ] + lines = [ x.split(',') for x in lines ] + assert lines[0][0] == 'ident' + assert lines[1][0] == 'GCF_000017325' + assert lines[2][0] == 'GCF_000021665' + assert len(lines) == 3 + + assert "searching 1 taxonomy files for 'Shew'" in err + assert 'found 2 matches; saved identifiers to picklist' in err + + +def test_tax_grep_search_shew_lowercase(runtmp): + # test 'tax grep shew' (lowercase), save result to a file + taxfile = utils.get_test_data('tax/test.taxonomy.csv') + + runtmp.sourmash('tax', 'grep', 'shew', '-t', taxfile, '-o', 'pick.csv') + + err = runtmp.last_result.err + assert "searching 1 taxonomy files for 'shew'" in err + assert 'found 0 matches; saved identifiers to picklist' in err + + runtmp.sourmash('tax', 'grep', '-i', 'shew', + '-t', taxfile, '-o', 'pick.csv') + + err = runtmp.last_result.err + assert "searching 1 taxonomy files for 'shew'" in err + assert 'found 2 matches; saved identifiers to picklist' in err + + out = open(runtmp.output('pick.csv')).read() + lines = [ x.strip() for x in out.splitlines() ] + lines = [ x.split(',') for x in lines ] + assert lines[0][0] == 'ident' + assert lines[1][0] == 'GCF_000017325.1' + assert lines[2][0] == 'GCF_000021665.1' + assert len(lines) == 3 + + +def test_tax_grep_search_shew_out_use_picklist(runtmp): + # test 'tax grep Shew', output to a picklist, use picklist + taxfile = utils.get_test_data('tax/test.taxonomy.csv') + dbfile = utils.get_test_data('tax/gtdb-tax-grep.sigs.zip') + + runtmp.sourmash('tax', 'grep', 'Shew', '-t', taxfile, '-o', 'pick.csv') + + runtmp.sourmash('sig', 'cat', dbfile, '--picklist', + 'pick.csv:ident:ident', '-o', 'pick-out.zip') + + all_sigs = sourmash.load_file_as_index(dbfile) + assert len(all_sigs) == 3 + + pick_sigs = sourmash.load_file_as_index(runtmp.output('pick-out.zip')) + assert len(pick_sigs) == 2 + + names = [ ss.name.split()[0] for ss in pick_sigs.signatures() ] + assert len(names) == 2 + assert 'GCF_000017325.1' in names + assert 'GCF_000021665.1' in names + + +def test_tax_grep_search_shew_invert(runtmp): + # test 'tax grep -v Shew' + taxfile = utils.get_test_data('tax/test.taxonomy.csv') + + runtmp.sourmash('tax', 'grep', '-v', 'Shew', '-t', taxfile) + + out = runtmp.last_result.out + err = runtmp.last_result.err + + assert "-v/--invert-match specified; returning only lineages that do not match." in err + + lines = [ x.strip() for x in out.splitlines() ] + lines = [ x.split(',') for x in lines ] + assert lines[0][0] == 'ident' + assert lines[1][0] == 'GCF_001881345.1' + assert lines[2][0] == 'GCF_003471795.1' + assert len(lines) == 5 + + assert "searching 1 taxonomy files for 'Shew'" in err + assert 'found 4 matches; saved identifiers to picklist' in err + + all_names = set([ x[0] for x in lines ]) + assert 'GCF_000017325.1' not in all_names + assert 'GCF_000021665.1' not in all_names + + +def test_tax_grep_search_shew_invert_select_phylum(runtmp): + # test 'tax grep -v Shew -r phylum' + taxfile = utils.get_test_data('tax/test.taxonomy.csv') + + runtmp.sourmash('tax', 'grep', '-v', 'Shew', '-t', taxfile, '-r', 'phylum') + + out = runtmp.last_result.out + err = runtmp.last_result.err + + assert "-v/--invert-match specified; returning only lineages that do not match." in err + assert "limiting matches to phylum" + + lines = [ x.strip() for x in out.splitlines() ] + lines = [ x.split(',') for x in lines ] + assert lines[0][0] == 'ident' + assert len(lines) == 7 + + assert "searching 1 taxonomy files for 'Shew'" in err + assert 'found 6 matches; saved identifiers to picklist' in err + + all_names = set([ x[0] for x in lines ]) + assert 'GCF_000017325.1' in all_names + assert 'GCF_000021665.1' in all_names + + +def test_tax_grep_search_shew_invert_select_bad_rank(runtmp): + # test 'tax grep -v Shew -r badrank' - should fail + taxfile = utils.get_test_data('tax/test.taxonomy.csv') + + with pytest.raises(SourmashCommandFailed): + runtmp.sourmash('tax', 'grep', '-v', 'Shew', '-t', taxfile, + '-r', 'badrank') + + out = runtmp.last_result.out + err = runtmp.last_result.err + + print(err) + assert 'error: argument -r/--rank: invalid choice:' in err + + +def test_tax_grep_search_shew_count(runtmp): + # test 'tax grep Shew --count' + taxfile = utils.get_test_data('tax/test.taxonomy.csv') + + runtmp.sourmash('tax', 'grep', 'Shew', '-t', taxfile, '-c') + + out = runtmp.last_result.out + err = runtmp.last_result.err + + assert not out.strip() + + assert "searching 1 taxonomy files for 'Shew'" in err + assert not 'found 2 matches; saved identifiers to picklist' in err + + +def test_tax_grep_multiple_csv(runtmp): + # grep on multiple CSVs + tax1 = utils.get_test_data('tax/test.taxonomy.csv') + tax2 = utils.get_test_data('tax/protozoa_genbank_lineage.csv') + + taxout = runtmp.output('out.csv') + + runtmp.sourmash('tax', 'grep', "Toxo|Gamma", + '-t', tax1, tax2, + '-o', taxout) + + out = runtmp.last_result.out + err = runtmp.last_result.err + + assert not out + assert "found 4 matches" in err + + lines = open(taxout).readlines() + assert len(lines) == 5 + + names = set([ x.split(',')[0] for x in lines ]) + assert 'GCA_000256725' in names + assert 'GCF_000017325.1' in names + assert 'GCF_000021665.1' in names + assert 'GCF_001881345.1' in names + + +def test_tax_grep_multiple_csv_empty_force(runtmp): + # grep on multiple CSVs, one empty, with --force + tax1 = utils.get_test_data('tax/test.taxonomy.csv') + tax2 = utils.get_test_data('tax/protozoa_genbank_lineage.csv') + tax_empty = runtmp.output('t.csv') + + taxout = runtmp.output('out.csv') + with open(tax_empty, "w") as fp: + fp.write("") + print("t_csv: ", tax_empty) + + runtmp.sourmash('tax', 'grep', "Toxo|Gamma", + '-t', tax1, tax2, '-t', tax_empty, + '-o', taxout, '--force') + + out = runtmp.last_result.out + err = runtmp.last_result.err + + assert not out + assert "found 4 matches" in err + + lines = open(taxout).readlines() + assert len(lines) == 5 + + names = set([ x.split(',')[0] for x in lines ]) + assert 'GCA_000256725' in names + assert 'GCF_000017325.1' in names + assert 'GCF_000021665.1' in names + assert 'GCF_001881345.1' in names + + +def test_tax_grep_duplicate_csv(runtmp): + # grep on duplicates => should collapse to uniques on identifiers + tax1 = utils.get_test_data('tax/test.taxonomy.csv') + + taxout = runtmp.output('out.csv') + + runtmp.sourmash('tax', 'grep', "Gamma", + '-t', tax1, tax1, + '-o', taxout) + + out = runtmp.last_result.out + err = runtmp.last_result.err + + assert not out + assert "found 3 matches" in err + + lines = open(taxout).readlines() + assert len(lines) == 4 + + names = set([ x.split(',')[0] for x in lines ]) + assert 'GCF_000017325.1' in names + assert 'GCF_000021665.1' in names + assert 'GCF_001881345.1' in names + + +def test_tax_summarize(runtmp): + # test basic operation with summarize + taxfile = utils.get_test_data('tax/test.taxonomy.csv') + + runtmp.sourmash('tax', 'summarize', taxfile) + + out = runtmp.last_result.out + err = runtmp.last_result.err + + assert "number of distinct taxonomic lineages: 6" in out + assert "rank superkingdom: 1 distinct taxonomic lineages" in out + assert "rank phylum: 2 distinct taxonomic lineages" in out + assert "rank class: 2 distinct taxonomic lineages" in out + assert "rank order: 2 distinct taxonomic lineages" in out + assert "rank family: 3 distinct taxonomic lineages" in out + assert "rank genus: 4 distinct taxonomic lineages" in out + assert "rank species: 4 distinct taxonomic lineages" in out + + +def test_tax_summarize_multiple(runtmp): + # test basic operation with summarize on multiple files + tax1 = utils.get_test_data('tax/bacteria_refseq_lineage.csv') + tax2 = utils.get_test_data('tax/protozoa_genbank_lineage.csv') + + runtmp.sourmash('tax', 'summarize', tax1, tax2) + + out = runtmp.last_result.out + err = runtmp.last_result.err + + assert "number of distinct taxonomic lineages: 6" in out + assert "rank superkingdom: 2 distinct taxonomic lineages" in out + assert "rank phylum: 3 distinct taxonomic lineages" in out + assert "rank class: 4 distinct taxonomic lineages" in out + assert "rank order: 4 distinct taxonomic lineages" in out + assert "rank family: 5 distinct taxonomic lineages" in out + assert "rank genus: 5 distinct taxonomic lineages" in out + assert "rank species: 5 distinct taxonomic lineages" in out + + +def test_tax_summarize_empty_line(runtmp): + # test basic operation with summarize on a file w/empty line + taxfile = utils.get_test_data('tax/test-empty-line.taxonomy.csv') + + runtmp.sourmash('tax', 'summarize', taxfile) + + out = runtmp.last_result.out + err = runtmp.last_result.err + + assert "number of distinct taxonomic lineages: 6" in out + assert "rank superkingdom: 1 distinct taxonomic lineages" in out + assert "rank phylum: 2 distinct taxonomic lineages" in out + assert "rank class: 2 distinct taxonomic lineages" in out + assert "rank order: 2 distinct taxonomic lineages" in out + assert "rank family: 3 distinct taxonomic lineages" in out + assert "rank genus: 4 distinct taxonomic lineages" in out + assert "rank species: 4 distinct taxonomic lineages" in out + + +def test_tax_summarize_empty(runtmp): + # test failure on empty file + taxfile = runtmp.output('no-exist') + + with pytest.raises(SourmashCommandFailed): + runtmp.sourmash('tax', 'summarize', taxfile) + + out = runtmp.last_result.out + err = runtmp.last_result.err + assert "ERROR while loading taxonomies" in err + + +def test_tax_summarize_csv(runtmp): + # test basic operation w/csv output + taxfile = utils.get_test_data('tax/test.taxonomy.csv') + + runtmp.sourmash('tax', 'summarize', taxfile, '-o', 'ranks.csv') + + out = runtmp.last_result.out + err = runtmp.last_result.err + + assert "number of distinct taxonomic lineages: 6" in out + assert "saved 18 lineage counts to 'ranks.csv'" in err + + csv_out = runtmp.output('ranks.csv') + + with sourmash_args.FileInputCSV(csv_out) as r: + # count number across ranks as a cheap consistency check + c = Counter() + for row in r: + val = row['lineage_count'] + c[val] += 1 + + assert c['3'] == 7 + assert c['2'] == 5 + assert c['1'] == 5 + + +def test_tax_summarize_on_annotate(runtmp): + # test summarize on output of annotate basics + g_csv = utils.get_test_data('tax/test1.gather.csv') + tax = utils.get_test_data('tax/test.taxonomy.csv') + csvout = runtmp.output("test1.gather.with-lineages.csv") + out_dir = os.path.dirname(csvout) + + runtmp.run_sourmash('tax', 'annotate', '--gather-csv', g_csv, '--taxonomy-csv', tax, '-o', out_dir) + + print(runtmp.last_result.status) + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + assert runtmp.last_result.status == 0 + assert os.path.exists(csvout) + + # so far so good - now see if we can run summarize! + + runtmp.run_sourmash('tax', 'summarize', csvout) + out = runtmp.last_result.out + err = runtmp.last_result.err + + print(out) + print(err) + + assert "number of distinct taxonomic lineages: 4" in out + assert "rank superkingdom: 1 distinct taxonomic lineages" in out + assert "rank phylum: 2 distinct taxonomic lineages" in out + assert "rank class: 2 distinct taxonomic lineages" in out + assert "rank order: 2 distinct taxonomic lineages" in out + assert "rank family: 2 distinct taxonomic lineages" in out + assert "rank genus: 3 distinct taxonomic lineages" in out + assert "rank species: 3 distinct taxonomic lineages" in out + + +def test_tax_summarize_strain_csv(runtmp): + # test basic operation w/csv output on taxonomy with strains + taxfile = utils.get_test_data('tax/test-strain.taxonomy.csv') + + runtmp.sourmash('tax', 'summarize', taxfile, '-o', 'ranks.csv') + + out = runtmp.last_result.out + err = runtmp.last_result.err + + assert "number of distinct taxonomic lineages: 6" in out + assert "saved 24 lineage counts to 'ranks.csv'" in err + + csv_out = runtmp.output('ranks.csv') + + with sourmash_args.FileInputCSV(csv_out) as r: + # count number across ranks as a cheap consistency check + c = Counter() + for row in r: + print(row) + val = row['lineage_count'] + c[val] += 1 + + print(list(c.most_common())) + + assert c['3'] == 7 + assert c['2'] == 5 + assert c['6'] == 1 + assert c['1'] == 11 + + +def test_tax_summarize_strain_csv_with_lineages(runtmp): + # test basic operation w/csv output on lineages-style file w/strain csv + taxfile = utils.get_test_data('tax/test-strain.taxonomy.csv') + lineage_csv = runtmp.output('lin-with-strains.csv') + + taxdb = tax_utils.LineageDB.load(taxfile) + with open(lineage_csv, 'w', newline="") as fp: + w = csv.writer(fp) + w.writerow(['name', 'lineage']) + for k, v in taxdb.items(): + linstr = lca_utils.display_lineage(v) + w.writerow([k, linstr]) + + runtmp.sourmash('tax', 'summarize', lineage_csv, '-o', 'ranks.csv') + + out = runtmp.last_result.out + err = runtmp.last_result.err + + assert "number of distinct taxonomic lineages: 6" in out + assert "saved 24 lineage counts to" in err + + csv_out = runtmp.output('ranks.csv') + + with sourmash_args.FileInputCSV(csv_out) as r: + # count number across ranks as a cheap consistency check + c = Counter() + for row in r: + print(row) + val = row['lineage_count'] + c[val] += 1 + + print(list(c.most_common())) + + assert c['3'] == 7 + assert c['2'] == 5 + assert c['6'] == 1 + assert c['1'] == 11 + + +def test_tax_summarize_LINS(runtmp): + # test basic operation w/LINs + taxfile = utils.get_test_data('tax/test.LIN-taxonomy.csv') + lineage_csv = runtmp.output('annotated-lin.csv') + + taxdb = tax_utils.LineageDB.load(taxfile, lins=True) + with open(lineage_csv, 'w', newline="") as fp: + w = csv.writer(fp) + w.writerow(['name', 'lineage']) + for k, v in taxdb.items(): + lin = tax_utils.LINLineageInfo(lineage=v) + linstr = lin.display_lineage(truncate_empty=False) + print(linstr) + w.writerow([k, linstr]) + + runtmp.sourmash('tax', 'summarize', lineage_csv, '-o', 'ranks.csv', '--lins') + + out = runtmp.last_result.out + err = runtmp.last_result.err + + print(out) + print(err) + + assert "number of distinct taxonomic lineages: 6" in out + assert "saved 91 lineage counts to" in err + + csv_out = runtmp.output('ranks.csv') + + with sourmash_args.FileInputCSV(csv_out) as r: + # count number across ranks as a cheap consistency check + c = Counter() + for row in r: + print(row) + val = row['lineage_count'] + c[val] += 1 + + print(list(c.most_common())) + + assert c['1'] == 77 + assert c['2'] == 1 + assert c['3'] == 11 + assert c['4'] == 2 + + +def test_metagenome_LIN(runtmp): + # test basic metagenome with LIN taxonomy + c = runtmp + + g_csv = utils.get_test_data('tax/test1.gather.csv') + tax = utils.get_test_data('tax/test.LIN-taxonomy.csv') + + c.run_sourmash('tax', 'metagenome', '-g', g_csv, '--taxonomy-csv', tax, '--lins') + + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + + assert c.last_result.status == 0 + assert 'query_name,rank,fraction,lineage,query_md5,query_filename,f_weighted_at_rank,bp_match_at_rank' in c.last_result.out + # 0th rank/position + assert "test1,0,0.089,1,md5,test1.sig,0.057,444000,0.925,0" in c.last_result.out + assert "test1,0,0.088,0,md5,test1.sig,0.058,442000,0.925,0" in c.last_result.out + assert "test1,0,0.028,2,md5,test1.sig,0.016,138000,0.891,0" in c.last_result.out + assert "test1,0,0.796,unclassified,md5,test1.sig,0.869,3990000,,0" in c.last_result.out + # 1st rank/position + assert "test1,1,0.089,1;0,md5,test1.sig,0.057,444000,0.925,0" in c.last_result.out + assert "test1,1,0.088,0;0,md5,test1.sig,0.058,442000,0.925,0" in c.last_result.out + assert "test1,1,0.028,2;0,md5,test1.sig,0.016,138000,0.891,0" in c.last_result.out + assert "test1,1,0.796,unclassified,md5,test1.sig,0.869,3990000,,0" in c.last_result.out + # 2nd rank/position + assert "test1,2,0.088,0;0;0,md5,test1.sig,0.058,442000,0.925,0" in c.last_result.out + assert "test1,2,0.078,1;0;0,md5,test1.sig,0.050,390000,0.921,0" in c.last_result.out + assert "test1,2,0.028,2;0;0,md5,test1.sig,0.016,138000,0.891,0" in c.last_result.out + assert "test1,2,0.011,1;0;1,md5,test1.sig,0.007,54000,0.864,0" in c.last_result.out + assert "test1,2,0.796,unclassified,md5,test1.sig,0.869,3990000,,0" in c.last_result.out + # 19th rank/position + assert "test1,19,0.088,0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0,md5,test1.sig,0.058,442000,0.925,0" in c.last_result.out + assert "test1,19,0.078,1;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0,md5,test1.sig,0.050,390000,0.921,0" in c.last_result.out + assert "test1,19,0.028,2;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0,md5,test1.sig,0.016,138000,0.891,0" in c.last_result.out + assert "test1,19,0.011,1;0;1;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0,md5,test1.sig,0.007,54000,0.864,0" in c.last_result.out + assert "test1,19,0.796,unclassified,md5,test1.sig,0.869,3990000,,0" in c.last_result.out + + +def test_metagenome_LIN_lingroups(runtmp): + # test lingroups output + c = runtmp + + g_csv = utils.get_test_data('tax/test1.gather.v450.csv') + tax = utils.get_test_data('tax/test.LIN-taxonomy.csv') + + lg_file = runtmp.output("test.lg.csv") + with open(lg_file, 'w') as out: + out.write('lin,name\n') + out.write('0;0;0,lg1\n') + out.write('1;0;0,lg2\n') + out.write('2;0;0,lg3\n') + out.write('1;0;1,lg3\n') + # write a 19 so we can check the end + out.write('1;0;1;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0,lg4\n') + + c.run_sourmash('tax', 'metagenome', '-g', g_csv, '--taxonomy-csv', tax, + '--lins', '--lingroup', lg_file) + + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) - db2 = tax_utils.MultiLineageDB.load([taxout]) + assert c.last_result.status == 0 + assert "Starting summarization up rank(s): 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0" in c.last_result.err + assert "Read 5 lingroup rows and found 5 distinct lingroup prefixes." in c.last_result.err + assert "name lin percent_containment num_bp_contained" in c.last_result.out + assert "lg1 0;0;0 5.82 714000" in c.last_result.out + assert "lg2 1;0;0 5.05 620000" in c.last_result.out + assert "lg3 2;0;0 1.56 192000" in c.last_result.out + assert "lg3 1;0;1 0.65 80000" in c.last_result.out + assert "lg4 1;0;1;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0 0.65 80000" in c.last_result.out - assert set(db1) == set(db2) +def test_metagenome_LIN_human_summary_no_lin_position(runtmp): + c = runtmp -def test_tax_prepare_2_csv_to_sql(runtmp, keep_identifiers, keep_versions): - # CSV -> SQL; same assignments? - tax = utils.get_test_data('tax/test.taxonomy.csv') - taxout = runtmp.output('out.db') + g_csv = utils.get_test_data('tax/test1.gather.v450.csv') + tax = utils.get_test_data('tax/test.LIN-taxonomy.csv') - args = [] - if keep_identifiers: - args.append('--keep-full-identifiers') - if keep_versions: - args.append('--keep-identifier-versions') + c.run_sourmash('tax', 'metagenome', '-g', g_csv, '--taxonomy-csv', tax, + '--lins', '-F', "human") - # this is an error - can't strip versions if not splitting identifiers - if keep_identifiers and not keep_versions: - with pytest.raises(SourmashCommandFailed): - runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', taxout, - '-F', 'sql', *args) - return + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) - runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', taxout, - '-F', 'sql', *args) - assert os.path.exists(taxout) + assert c.last_result.status == 0 + assert "Starting summarization up rank(s): 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0" in c.last_result.err + assert "sample name proportion cANI lineage" in c.last_result.out + assert "----------- ---------- ---- -------" in c.last_result.out + assert "test1 86.9% - unclassified" in c.last_result.out + assert "test1 5.8% 92.5% 0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0" in c.last_result.out + assert "test1 5.0% 92.1% 1;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0" in c.last_result.out + assert "test1 1.6% 89.1% 2;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0" in c.last_result.out + assert "test1 0.7% 86.4% 1;0;1;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0" in c.last_result.out - db1 = tax_utils.MultiLineageDB.load([tax], - keep_full_identifiers=keep_identifiers, - keep_identifier_versions=keep_versions) - db2 = tax_utils.MultiLineageDB.load([taxout]) - assert set(db1) == set(db2) +def test_metagenome_LIN_human_summary_lin_position_5(runtmp): + c = runtmp - # cannot overwrite - - with pytest.raises(SourmashCommandFailed) as exc: - runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', taxout, - '-F', 'sql', *args) - assert 'taxonomy table already exists' in str(exc.value) + g_csv = utils.get_test_data('tax/test1.gather.v450.csv') + tax = utils.get_test_data('tax/test.LIN-taxonomy.csv') + c.run_sourmash('tax', 'metagenome', '-g', g_csv, '--taxonomy-csv', tax, + '--lins', '-F', "human", '--lin-position', '5') -def test_tax_prepare_2_csv_to_sql_empty_ranks(runtmp, keep_identifiers, keep_versions): - # CSV -> SQL with some empty ranks in the taxonomy file - tax = utils.get_test_data('tax/test-empty-ranks.taxonomy.csv') - taxout = runtmp.output('out.db') + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) - args = [] - if keep_identifiers: - args.append('--keep-full-identifiers') - if keep_versions: - args.append('--keep-identifier-versions') + assert c.last_result.status == 0 + assert "Starting summarization up rank(s): 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0" in c.last_result.err + assert "sample name proportion cANI lineage" in c.last_result.out + assert "----------- ---------- ---- -------" in c.last_result.out + assert "test1 86.9% - unclassified" in c.last_result.out + assert "test1 5.8% 92.5% 0;0;0;0;0;0" in c.last_result.out + assert "test1 5.0% 92.1% 1;0;0;0;0;0" in c.last_result.out + assert "test1 1.6% 89.1% 2;0;0;0;0;0" in c.last_result.out + assert "test1 0.7% 86.4% 1;0;1;0;0;0" in c.last_result.out - # this is an error - can't strip versions if not splitting identifiers - if keep_identifiers and not keep_versions: - with pytest.raises(SourmashCommandFailed): - runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', taxout, - '-F', 'sql', *args) - return - runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', taxout, - '-F', 'sql', *args) - assert os.path.exists(taxout) +def test_metagenome_LIN_krona_lin_position_5(runtmp): + c = runtmp - db1 = tax_utils.MultiLineageDB.load([tax], - keep_full_identifiers=keep_identifiers, - keep_identifier_versions=keep_versions) - db2 = tax_utils.MultiLineageDB.load([taxout]) + g_csv = utils.get_test_data('tax/test1.gather.v450.csv') + tax = utils.get_test_data('tax/test.LIN-taxonomy.csv') - assert set(db1) == set(db2) + c.run_sourmash('tax', 'metagenome', '-g', g_csv, '--taxonomy-csv', tax, + '--lins', '-F', "krona", '--lin-position', '5') + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) -def test_tax_prepare_3_db_to_csv(runtmp): - # SQL -> CSV; same assignments - taxcsv = utils.get_test_data('tax/test.taxonomy.csv') - taxdb = utils.get_test_data('tax/test.taxonomy.db') - taxout = runtmp.output('out.csv') + assert c.last_result.status == 0 + assert "Starting summarization up rank(s): 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0" in c.last_result.err + assert "fraction 0 1 2 3 4 5" in c.last_result.out + assert "0.08815317112086159 0 0 0 0 0 0" in c.last_result.out + assert "0.07778220981252493 1 0 0 0 0 0" in c.last_result.out + assert "0.027522935779816515 2 0 0 0 0 0" in c.last_result.out + assert "0.010769844435580374 1 0 1 0 0 0" in c.last_result.out + assert "0.7957718388512166 unclassified unclassified unclassified unclassified unclassified unclassified" in c.last_result.out - runtmp.run_sourmash('tax', 'prepare', '-t', taxdb, - '-o', taxout, '-F', 'csv') - assert os.path.exists(taxout) - with open(taxout) as fp: - print(fp.read()) - db1 = tax_utils.MultiLineageDB.load([taxcsv], - keep_full_identifiers=False, - keep_identifier_versions=False) +def test_metagenome_LIN_krona_bad_rank(runtmp): + c = runtmp - db2 = tax_utils.MultiLineageDB.load([taxout]) - db3 = tax_utils.MultiLineageDB.load([taxdb], - keep_full_identifiers=False, - keep_identifier_versions=False) - assert set(db1) == set(db2) - assert set(db1) == set(db3) + g_csv = utils.get_test_data('tax/test1.gather.v450.csv') + tax = utils.get_test_data('tax/test.LIN-taxonomy.csv') + with pytest.raises(SourmashCommandFailed): + c.run_sourmash('tax', 'metagenome', '-g', g_csv, '--taxonomy-csv', tax, + '--lins', '-F', "krona", '--lin-position', 'strain') -def test_tax_prepare_2_csv_to_sql_empty_ranks_2(runtmp, keep_identifiers, keep_versions): - # CSV -> SQL with some empty internal ranks in the taxonomy file - tax = utils.get_test_data('tax/test-empty-ranks-2.taxonomy.csv') - taxout = runtmp.output('out.db') + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) - args = [] - if keep_identifiers: - args.append('--keep-full-identifiers') - if keep_versions: - args.append('--keep-identifier-versions') + assert c.last_result.status != 0 + assert "Invalid '--rank'/'--position' input: 'strain'. '--lins' is specified. Rank must be an integer corresponding to a LIN position." in c.last_result.err - # this is an error - can't strip versions if not splitting identifiers - if keep_identifiers and not keep_versions: - with pytest.raises(SourmashCommandFailed): - runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', taxout, - '-F', 'sql', *args) - return - runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', taxout, - '-F', 'sql', *args) - assert os.path.exists(taxout) - db1 = tax_utils.MultiLineageDB.load([tax], - keep_full_identifiers=keep_identifiers, - keep_identifier_versions=keep_versions) - db2 = tax_utils.MultiLineageDB.load([taxout]) +def test_metagenome_LIN_lingroups_empty_lg_file(runtmp): + c = runtmp - assert set(db1) == set(db2) + g_csv = utils.get_test_data('tax/test1.gather.v450.csv') + tax = utils.get_test_data('tax/test.LIN-taxonomy.csv') + lg_file = runtmp.output("test.lg.csv") + with open(lg_file, 'w') as out: + out.write("") -def test_tax_prepare_2_csv_to_sql_empty_ranks_3(runtmp, keep_identifiers, keep_versions): - # CSV -> SQL with some empty internal ranks in the taxonomy file - tax = utils.get_test_data('tax/test-empty-ranks-3.taxonomy.csv') - taxout = runtmp.output('out.db') + with pytest.raises(SourmashCommandFailed): + c.run_sourmash('tax', 'metagenome', '-g', g_csv, '--taxonomy-csv', tax, + '--lins', '--lingroup', lg_file) - args = [] - if keep_identifiers: - args.append('--keep-full-identifiers') - if keep_versions: - args.append('--keep-identifier-versions') + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) - # this is an error - can't strip versions if not splitting identifiers - if keep_identifiers and not keep_versions: - with pytest.raises(SourmashCommandFailed): - runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', taxout, - '-F', 'sql', *args) - return + assert c.last_result.status != 0 + assert "Starting summarization up rank(s): 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0" in c.last_result.err + assert f"Cannot read lingroups from '{lg_file}'. Is file empty?" in c.last_result.err - runtmp.run_sourmash('tax', 'prepare', '-t', tax, '-o', taxout, - '-F', 'sql', *args) - assert os.path.exists(taxout) - db1 = tax_utils.MultiLineageDB.load([tax], - keep_full_identifiers=keep_identifiers, - keep_identifier_versions=keep_versions) - db2 = tax_utils.MultiLineageDB.load([taxout]) +def test_metagenome_LIN_lingroups_bad_cli_inputs(runtmp): + c = runtmp - assert set(db1) == set(db2) + g_csv = utils.get_test_data('tax/test1.gather.v450.csv') + tax = utils.get_test_data('tax/test.LIN-taxonomy.csv') + lg_file = runtmp.output("test.lg.csv") + with open(lg_file, 'w') as out: + out.write("") -def test_tax_prepare_3_db_to_csv_empty_ranks(runtmp): - # SQL -> CSV; same assignments, with empty ranks - taxcsv = utils.get_test_data('tax/test-empty-ranks.taxonomy.csv') - taxdb = utils.get_test_data('tax/test-empty-ranks.taxonomy.db') - taxout = runtmp.output('out.csv') + with pytest.raises(SourmashCommandFailed): + c.run_sourmash('tax', 'metagenome', '-g', g_csv, '--taxonomy-csv', tax, + '--lins', '-F', "lingroup") - runtmp.run_sourmash('tax', 'prepare', '-t', taxdb, - '-o', taxout, '-F', 'csv') - assert os.path.exists(taxout) - with open(taxout) as fp: - print(fp.read()) + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) - db1 = tax_utils.MultiLineageDB.load([taxcsv], - keep_full_identifiers=False, - keep_identifier_versions=False) + assert c.last_result.status != 0 + assert "Must provide lingroup csv via '--lingroup' in order to output a lingroup report." in c.last_result.err - db2 = tax_utils.MultiLineageDB.load([taxout]) - db3 = tax_utils.MultiLineageDB.load([taxdb], - keep_full_identifiers=False, - keep_identifier_versions=False) - assert set(db1) == set(db2) - assert set(db1) == set(db3) + with pytest.raises(SourmashCommandFailed): + c.run_sourmash('tax', 'metagenome', '-g', g_csv, '--taxonomy-csv', tax, '-F', "lingroup") + print(c.last_result.err) + assert c.last_result.status != 0 + assert "Must enable LIN taxonomy via '--lins' in order to use lingroups." in c.last_result.err + with pytest.raises(SourmashCommandFailed): + c.run_sourmash('tax', 'metagenome', '-g', g_csv, '--taxonomy-csv', tax, '--lingroup', lg_file) + print(c.last_result.err) + assert c.last_result.status != 0 -def test_tax_prepare_3_db_to_csv_empty_ranks_2(runtmp): - # SQL -> CSV; same assignments, with empty ranks - taxcsv = utils.get_test_data('tax/test-empty-ranks-2.taxonomy.csv') - taxdb = utils.get_test_data('tax/test-empty-ranks-2.taxonomy.db') - taxout = runtmp.output('out.csv') + with pytest.raises(SourmashCommandFailed): + c.run_sourmash('tax', 'metagenome', '-g', g_csv, '--taxonomy-csv', tax, '--lins', '-F', 'bioboxes') + print(c.last_result.err) + assert c.last_result.status != 0 + assert "ERROR: The following outputs are incompatible with '--lins': : bioboxes, kreport" in c.last_result.err - runtmp.run_sourmash('tax', 'prepare', '-t', taxdb, - '-o', taxout, '-F', 'csv') - assert os.path.exists(taxout) - with open(taxout) as fp: - print(fp.read()) - db1 = tax_utils.MultiLineageDB.load([taxcsv], - keep_full_identifiers=False, - keep_identifier_versions=False) +def test_metagenome_mult_outputs_stdout_fail(runtmp): + c = runtmp - db2 = tax_utils.MultiLineageDB.load([taxout]) - db3 = tax_utils.MultiLineageDB.load([taxdb], - keep_full_identifiers=False, - keep_identifier_versions=False) - assert set(db1) == set(db2) - assert set(db1) == set(db3) + g_csv = utils.get_test_data('tax/test1.gather.v450.csv') + tax = utils.get_test_data('tax/test.LIN-taxonomy.csv') + with pytest.raises(SourmashCommandFailed): + c.run_sourmash('tax', 'metagenome', '-g', g_csv, '--taxonomy-csv', tax, + '-F', "kreport", 'csv_summary') -def test_tax_prepare_3_db_to_csv_empty_ranks_3(runtmp): - # SQL -> CSV; same assignments, with empty ranks - taxcsv = utils.get_test_data('tax/test-empty-ranks-3.taxonomy.csv') - taxdb = utils.get_test_data('tax/test-empty-ranks-3.taxonomy.db') - taxout = runtmp.output('out.csv') + print(c.last_result.err) + assert c.last_result.status != 0 + assert f"Writing to stdout is incompatible with multiple output formats ['kreport', 'csv_summary']" in c.last_result.err - runtmp.run_sourmash('tax', 'prepare', '-t', taxdb, - '-o', taxout, '-F', 'csv') - assert os.path.exists(taxout) - with open(taxout) as fp: - print(fp.read()) - db1 = tax_utils.MultiLineageDB.load([taxcsv], - keep_full_identifiers=False, - keep_identifier_versions=False) +def test_genome_mult_outputs_stdout_fail(runtmp): + c = runtmp - db2 = tax_utils.MultiLineageDB.load([taxout]) - db3 = tax_utils.MultiLineageDB.load([taxdb], - keep_full_identifiers=False, - keep_identifier_versions=False) - assert set(db1) == set(db2) - assert set(db1) == set(db3) + g_csv = utils.get_test_data('tax/test1.gather.v450.csv') + tax = utils.get_test_data('tax/test.LIN-taxonomy.csv') + with pytest.raises(SourmashCommandFailed): + c.run_sourmash('tax', 'genome', '-g', g_csv, '--taxonomy-csv', tax, + '-F', "lineage_csv", 'csv_summary') -def test_tax_prepare_sqlite_lineage_version(runtmp): - # test bad sourmash_internals version for SqliteLineage - taxcsv = utils.get_test_data('tax/test.taxonomy.csv') - taxout = runtmp.output('out.db') + print(c.last_result.err) + assert c.last_result.status != 0 + assert f"Writing to stdout is incompatible with multiple output formats ['lineage_csv', 'csv_summary']" in c.last_result.err - runtmp.run_sourmash('tax', 'prepare', '-t', taxcsv, - '-o', taxout, '-F', 'sql') - assert os.path.exists(taxout) - # set bad version - conn = sqlite_utils.open_sqlite_db(taxout) - c = conn.cursor() - c.execute("UPDATE sourmash_internal SET value='0.9' WHERE key='SqliteLineage'") +def test_metagenome_LIN_lingroups_lg_only_header(runtmp): + c = runtmp - conn.commit() - conn.close() + g_csv = utils.get_test_data('tax/test1.gather.v450.csv') + tax = utils.get_test_data('tax/test.LIN-taxonomy.csv') - with pytest.raises(IndexNotSupported): - db = tax_utils.MultiLineageDB.load([taxout]) + lg_file = runtmp.output("test.lg.csv") + with open(lg_file, 'w') as out: + out.write('lin,name\n') -def test_tax_prepare_sqlite_no_lineage(): - # no lineage table at all - sqldb = utils.get_test_data('sqlite/index.sqldb') + with pytest.raises(SourmashCommandFailed): + c.run_sourmash('tax', 'metagenome', '-g', g_csv, '--taxonomy-csv', tax, + '--lins', '--lingroup', lg_file) - with pytest.raises(ValueError): - db = tax_utils.MultiLineageDB.load([sqldb]) + print(c.last_result.status) + print(c.last_result.out) + print(c.last_result.err) + + assert c.last_result.status != 0 + assert "Starting summarization up rank(s): 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0" in c.last_result.err + assert f"No lingroups loaded from {lg_file}" in c.last_result.err diff --git a/tests/test_tax_utils.py b/tests/test_tax_utils.py index 449e26f972..d97672bf14 100644 --- a/tests/test_tax_utils.py +++ b/tests/test_tax_utils.py @@ -1,44 +1,118 @@ """ Tests for functions in taxonomy submodule. """ + import pytest +from pytest import approx +import os from os.path import basename +import gzip import sourmash_tst_utils as utils from sourmash.tax.tax_utils import (ascending_taxlist, get_ident, load_gather_results, - summarize_gather_at, find_missing_identities, - write_summary, MultiLineageDB, collect_gather_csvs, check_and_load_gather_csvs, - SummarizedGatherResult, ClassificationResult, - write_classifications, - aggregate_by_lineage_at_rank, - make_krona_header, format_for_krona, write_krona, - combine_sumgather_csvs_by_lineage, write_lineage_sample_frac, - LineageDB, LineageDB_Sqlite) - -# import lca utils as needed for now -from sourmash.lca import lca_utils -from sourmash.lca.lca_utils import LineagePair + LineagePair, QueryInfo, GatherRow, TaxResult, QueryTaxResult, + SummarizedGatherResult, ClassificationResult, AnnotateTaxResult, + BaseLineageInfo, RankLineageInfo, LINLineageInfo, + aggregate_by_lineage_at_rank, format_for_krona, + write_krona, write_lineage_sample_frac, read_lingroups, + LineageTree, LineageDB, LineageDB_Sqlite, MultiLineageDB) # utility functions for testing -def make_mini_gather_results(g_infolist): - # make mini gather_results - min_header = ["query_name", "name", "match_ident", "f_unique_to_query", "query_md5", "query_filename", "f_unique_weighted", "unique_intersect_bp", "remaining_bp"] - gather_results = [] - for g_info in g_infolist: - inf = dict(zip(min_header, g_info)) - gather_results.append(inf) - return gather_results - - -def make_mini_taxonomy(tax_info): +def make_mini_taxonomy(tax_info, LIN=False): #pass in list of tuples: (name, lineage) taxD = {} - for (name,lin) in tax_info: - taxD[name] = lca_utils.make_lineage(lin) + for (name, lin) in tax_info: + if LIN: + lineage = LINLineageInfo(lineage_str=lin) + else: + lineage = RankLineageInfo(lineage_str=lin) + taxD[name] = lineage.filled_lineage + return taxD + +def make_mini_taxonomy_with_taxids(tax_info, LIN=False): + taxD = {} + for (name, lin, taxids) in tax_info: + if LIN: + lineage = LINLineageInfo(lineage_str=lin) + else: + ranks = RankLineageInfo.ranks + txs = taxids.split(';') + lns = lin.split(';') + lineage_tups = [] + for n, taxname in enumerate(lns): + rk = ranks[n] + tx = txs[n] + this_lineage = LineagePair(rk, name=taxname, taxid=tx) + lineage_tups.append(this_lineage) + lineage = RankLineageInfo(lineage=lineage_tups) + taxD[name] = lineage.filled_lineage return taxD +def make_GatherRow(gather_dict=None, exclude_cols=[]): + """Load artificial gather row (dict) into GatherRow class""" + # default contains just the essential cols + gatherD = {'query_name': 'q1', + 'query_md5': 'md5', + 'query_filename': 'query_fn', + 'name': 'gA', + 'f_unique_weighted': 0.2, + 'f_unique_to_query': 0.1, + 'query_bp':100, + 'unique_intersect_bp': 20, + 'remaining_bp': 1, + 'ksize': 31, + 'scaled': 1} + if gather_dict is not None: + gatherD.update(gather_dict) + for col in exclude_cols: + gatherD.pop(col) + gatherRaw = GatherRow(**gatherD) + return gatherRaw + + +def make_TaxResult(gather_dict=None, taxD=None, keep_full_ident=False, keep_ident_version=False, skip_idents=None, LIN=False): + """Make TaxResult from artificial gather row (dict)""" + gRow = make_GatherRow(gather_dict) + taxres = TaxResult(raw=gRow, keep_full_identifiers=keep_full_ident, + keep_identifier_versions=keep_ident_version, lins=LIN) + if taxD is not None: + taxres.get_match_lineage(tax_assignments=taxD, skip_idents=skip_idents) + return taxres + + +def make_QueryTaxResults(gather_info, taxD=None, single_query=False, keep_full_ident=False, keep_ident_version=False, + skip_idents=None, summarize=False, classify=False, classify_rank=None, c_thresh=0.1, ani_thresh=None, + LIN=False): + """Make QueryTaxResult(s) from artificial gather information, formatted as list of gather rows (dicts)""" + gather_results = {} + this_querytaxres = None + for gather_infoD in gather_info: + taxres = make_TaxResult(gather_infoD, taxD=taxD, keep_full_ident=keep_full_ident, + keep_ident_version=keep_ident_version, skip_idents=skip_idents, LIN=LIN) + query_name = taxres.query_name + # add to matching QueryTaxResult or create new one + if not this_querytaxres or not this_querytaxres.is_compatible(taxres): + # get existing or initialize new + this_querytaxres = gather_results.get(query_name, QueryTaxResult(taxres.query_info, lins=LIN)) + this_querytaxres.add_taxresult(taxres) +# print('missed_ident?', taxres.missed_ident) + gather_results[query_name] = this_querytaxres + if summarize: + for query_name, qres in gather_results.items(): + qres.build_summarized_result() + if classify: + for query_name, qres in gather_results.items(): + qres.build_classification_result(rank=classify_rank, containment_threshold=c_thresh, ani_threshold=ani_thresh) + # for convenience: If working with single query, just return that QueryTaxResult. + if single_query: + if len(gather_results.keys()) > 1: + raise ValueError("You passed in results for more than one query") + else: + return next(iter(gather_results.values())) + return gather_results + ## tests def test_ascending_taxlist_1(): @@ -49,24 +123,390 @@ def test_ascending_taxlist_2(): assert list(ascending_taxlist(include_strain=False)) == ['species', 'genus', 'family', 'order', 'class', 'phylum', 'superkingdom'] +def test_QueryInfo_basic(): + "basic functionality of QueryInfo dataclass" + qInf = QueryInfo(query_name='q1', query_md5='md5', query_filename='f1',query_bp='100',query_n_hashes='10',ksize='31',scaled='10', total_weighted_hashes='200') + assert qInf.query_name == 'q1' + assert isinstance(qInf.query_n_hashes, int) + assert isinstance(qInf.ksize, int) + assert isinstance(qInf.scaled, int) + assert qInf.total_weighted_hashes == 200 + assert qInf.total_weighted_bp == 2000 + + +def test_QueryInfo_no_hash_info(): + "QueryInfo dataclass for older gather results without query_n_hashes or total_weighted_hashes" + qInf = QueryInfo(query_name='q1', query_md5='md5', query_filename='f1',query_bp='100',ksize=31,scaled=10) + assert qInf.query_name == 'q1' + assert qInf.query_n_hashes == 0 + assert qInf.total_weighted_hashes == 0 + assert qInf.total_weighted_bp == 0 + + +def test_QueryInfo_missing(): + "check that required args" + with pytest.raises(TypeError) as exc: + QueryInfo(query_name='q1', query_filename='f1',query_bp='100',query_n_hashes='10',ksize=31,scaled=10, total_weighted_hashes=200) + print(str(exc)) + assert "missing 1 required positional argument: 'query_md5'" in str(exc) + + +def test_SummarizedGatherResult(): + "basic functionality of SummarizedGatherResult dataclass" + qInf = QueryInfo(query_name='q1', query_md5='md5', query_filename='f1',query_bp='100', + query_n_hashes='10',ksize='31',scaled='10', total_weighted_hashes='200') + sgr = SummarizedGatherResult(rank="phylum", fraction=0.2, lineage=RankLineageInfo(lineage_str="a;b"), + f_weighted_at_rank=0.3, bp_match_at_rank=30) + print(sgr) + assert sgr.rank=='phylum' + sumD = sgr.as_summary_dict(query_info=qInf) + print(sumD) + assert sumD == {'rank': 'phylum', 'fraction': "0.2", 'lineage': 'a;b', 'f_weighted_at_rank': "0.3", + 'bp_match_at_rank': "30", 'query_ani_at_rank': None, 'query_name': 'q1', + 'query_md5': 'md5', 'query_filename': 'f1', 'total_weighted_hashes': "200"} + hD = sgr.as_human_friendly_dict(query_info=qInf) + print(hD) + assert hD == {'rank': 'phylum', 'fraction': '0.200', 'lineage': 'a;b', 'f_weighted_at_rank': '30.0%', + 'bp_match_at_rank': "30", 'query_ani_at_rank': '- ', 'query_name': 'q1', + 'query_md5': 'md5', 'query_filename': 'f1', 'total_weighted_hashes': "200"} + krD = sgr.as_kreport_dict(query_info=qInf) + print(krD) + assert krD == {'ncbi_taxid': None, 'sci_name': 'b', 'rank_code': 'P', 'num_bp_assigned': "0", + 'percent_containment': '30.00', 'num_bp_contained': "600"} + lD = sgr.as_lineage_dict(ranks = RankLineageInfo().ranks, query_info=qInf) + print(lD) + assert lD == {'ident': 'q1', 'superkingdom': 'a', 'phylum': 'b', 'class': '', 'order': '', + 'family': '', 'genus': '', 'species': '', 'strain': ''} + cami = sgr.as_cami_bioboxes() + print(cami) + assert cami == [None, 'phylum', None, 'a|b', '30.00'] + + +def test_SummarizedGatherResult_withtaxids(): + "basic functionality of SummarizedGatherResult dataclass" + qInf = QueryInfo(query_name='q1', query_md5='md5', query_filename='f1',query_bp='100', + query_n_hashes='10',ksize='31',scaled='10', total_weighted_hashes='200') + lin = [LineagePair(rank='superkingdom', name='a', taxid='1'), LineagePair(rank='phylum', name='b', taxid=2)] + sgr = SummarizedGatherResult(rank="phylum", fraction=0.2, lineage=RankLineageInfo(lineage=lin), + f_weighted_at_rank=0.3, bp_match_at_rank=30) + print(sgr) + assert sgr.rank=='phylum' + sumD = sgr.as_summary_dict(query_info=qInf) + print(sumD) + assert sumD == {'rank': 'phylum', 'fraction': "0.2", 'lineage': 'a;b', 'f_weighted_at_rank': "0.3", + 'bp_match_at_rank': "30", 'query_ani_at_rank': None, 'query_name': 'q1', + 'query_md5': 'md5', 'query_filename': 'f1', 'total_weighted_hashes': "200"} + hD = sgr.as_human_friendly_dict(query_info=qInf) + print(hD) + assert hD == {'rank': 'phylum', 'fraction': '0.200', 'lineage': 'a;b', 'f_weighted_at_rank': '30.0%', + 'bp_match_at_rank': "30", 'query_ani_at_rank': '- ', 'query_name': 'q1', + 'query_md5': 'md5', 'query_filename': 'f1', 'total_weighted_hashes': "200"} + krD = sgr.as_kreport_dict(query_info=qInf) + print(krD) + assert krD == {'ncbi_taxid': '2', 'sci_name': 'b', 'rank_code': 'P', 'num_bp_assigned': "0", + 'percent_containment': '30.00', 'num_bp_contained': "600"} + lD = sgr.as_lineage_dict(ranks = RankLineageInfo().ranks, query_info=qInf) + print(lD) + assert lD == {'ident': 'q1', 'superkingdom': 'a', 'phylum': 'b', 'class': '', 'order': '', + 'family': '', 'genus': '', 'species': '', 'strain': ''} + cami = sgr.as_cami_bioboxes() + print(cami) + assert cami == ['2', 'phylum', '1|2', 'a|b', '30.00'] + + +def test_SummarizedGatherResult_LINs(): + "SummarizedGatherResult with LINs" + qInf = QueryInfo(query_name='q1', query_md5='md5', query_filename='f1',query_bp='100', + query_n_hashes='10',ksize='31',scaled='10', total_weighted_hashes='200') + sgr = SummarizedGatherResult(rank="phylum", fraction=0.2, lineage=LINLineageInfo(lineage_str="0;0;1"), + f_weighted_at_rank=0.3, bp_match_at_rank=30) + + lgD = sgr.as_lingroup_dict(query_info=qInf, lg_name="lg_name") + print(lgD) + assert lgD == {'name': "lg_name", "lin": "0;0;1", + 'percent_containment': '30.00', 'num_bp_contained': "600"} + lgD = sgr.as_lingroup_dict(query_info=qInf, lg_name="lg_name") + print(lgD) + assert lgD == {'name': "lg_name", "lin": "0;0;1", + 'percent_containment': '30.00', 'num_bp_contained': "600"} + with pytest.raises(ValueError) as exc: + sgr.as_kreport_dict(query_info=qInf) + print(str(exc)) + assert "Cannot produce 'kreport' with LIN taxonomy." in str(exc) + with pytest.raises(ValueError) as exc: + sgr.as_cami_bioboxes() + print(str(exc)) + assert "Cannot produce 'bioboxes' with LIN taxonomy." in str(exc) + + +def test_SummarizedGatherResult_set_query_ani(): + "Check ANI estimation within SummarizedGatherResult dataclass" + qInf = QueryInfo(query_name='q1', query_md5='md5', query_filename='f1',query_bp='100', + query_n_hashes='10',ksize='31',scaled='10', total_weighted_hashes='200') + sgr = SummarizedGatherResult(rank="phylum", fraction=0.2, lineage=RankLineageInfo(lineage_str="a;b"), + f_weighted_at_rank=0.3, bp_match_at_rank=30) + sgr.set_query_ani(query_info=qInf) + print(sgr.query_ani_at_rank) + assert sgr.query_ani_at_rank == approx(0.949, rel=1e-3) + # ANI can be calculated with query_bp OR query_n_hashes. Remove each and check the results are identical + qInf = QueryInfo(query_name='q1', query_md5='md5', query_filename='f1',query_bp='100', + query_n_hashes=0,ksize='31',scaled='10', total_weighted_hashes='200') + sgr = SummarizedGatherResult(rank="phylum", fraction=0.2, lineage=RankLineageInfo(lineage_str="a;b"), + f_weighted_at_rank=0.3, bp_match_at_rank=30) + sgr.set_query_ani(query_info=qInf) + print(sgr.query_ani_at_rank) + assert sgr.query_ani_at_rank == approx(0.949, rel=1e-3) + # try without query_bp + qInf = QueryInfo(query_name='q1', query_md5='md5', query_filename='f1',query_bp=0, + query_n_hashes='10',ksize='31',scaled='10', total_weighted_hashes='200') + sgr = SummarizedGatherResult(rank="phylum", fraction=0.2, lineage=RankLineageInfo(lineage_str="a;b"), + f_weighted_at_rank=0.3, bp_match_at_rank=30) + sgr.set_query_ani(query_info=qInf) + print(sgr.query_ani_at_rank) + assert sgr.query_ani_at_rank == approx(0.949, rel=1e-3) + + +def test_SummarizedGatherResult_greater_than_1(): + "basic functionality of SummarizedGatherResult dataclass" + # fraction > 1 + with pytest.raises(ValueError) as exc: + SummarizedGatherResult(rank="phylum", fraction=0.3, lineage=RankLineageInfo(lineage_str="a;b"), + f_weighted_at_rank=1.2, bp_match_at_rank=30) + print(str(exc)) + assert "> 100% of the query!" in str(exc) + # f_weighted > 1 + with pytest.raises(ValueError) as exc: + SummarizedGatherResult(rank="phylum", fraction=1.2, lineage=RankLineageInfo(lineage_str="a;b"), + f_weighted_at_rank=0.3, bp_match_at_rank=30) + print(str(exc)) + assert "> 100% of the query!" in str(exc) + + +def test_SummarizedGatherResult_0_fraction(): + with pytest.raises(ValueError) as exc: + SummarizedGatherResult(rank="phylum", fraction=-.1, lineage=RankLineageInfo(lineage_str="a;b"), + f_weighted_at_rank=0.3, bp_match_at_rank=30) + err_msg = "Summarized fraction is <=0% of the query! This should not occur." + assert err_msg in str(exc) + #assert cr.status == 'nomatch' + + with pytest.raises(ValueError) as exc: + SummarizedGatherResult(rank="phylum", fraction=.1, lineage=RankLineageInfo(lineage_str="a;b"), + f_weighted_at_rank=0, bp_match_at_rank=30) + print(str(exc)) + assert err_msg in str(exc) + + +def test_SummarizedGatherResult_species_kreport(): + "basic functionality of SummarizedGatherResult dataclass" + qInf = QueryInfo(query_name='q1', query_md5='md5', query_filename='f1',query_bp='100', + query_n_hashes='10',ksize='31',scaled='10', total_weighted_hashes='200') + sgr = SummarizedGatherResult(rank="species", fraction=0.2, lineage=RankLineageInfo(lineage_str="a;b;c;d;e;f;g"), + f_weighted_at_rank=0.3, bp_match_at_rank=30) + print(sgr) + assert sgr.rank=='species' + krD = sgr.as_kreport_dict(query_info=qInf) + print(krD) + assert krD == {'ncbi_taxid': None, 'sci_name': 'g', 'rank_code': 'S', 'num_bp_assigned': "600", + 'percent_containment': '30.00', 'num_bp_contained': "600"} + + +def test_SummarizedGatherResult_summary_dict_limit_float(): + "basic functionality of SummarizedGatherResult dataclass" + qInf = QueryInfo(query_name='q1', query_md5='md5', query_filename='f1',query_bp='100', + query_n_hashes='10',ksize='31',scaled='10', total_weighted_hashes='200') + sgr = SummarizedGatherResult(rank="phylum", fraction=0.123456, lineage=RankLineageInfo(lineage_str="a;b"), + f_weighted_at_rank=0.345678, bp_match_at_rank=30) + print(sgr) + assert sgr.rank=='phylum' + sumD = sgr.as_summary_dict(query_info=qInf) + print(sumD) + assert sumD == {'rank': 'phylum', 'fraction': "0.123456", 'lineage': 'a;b', 'f_weighted_at_rank': "0.345678", + 'bp_match_at_rank': "30", 'query_ani_at_rank': None, 'query_name': 'q1', + 'query_md5': 'md5', 'query_filename': 'f1', 'total_weighted_hashes': "200"} + + sumD = sgr.as_summary_dict(query_info=qInf, limit_float=True) + print(sumD) + assert sumD == {'rank': 'phylum', 'fraction': "0.123", 'lineage': 'a;b', 'f_weighted_at_rank': "0.346", + 'bp_match_at_rank': "30", 'query_ani_at_rank': None, 'query_name': 'q1', + 'query_md5': 'md5', 'query_filename': 'f1', 'total_weighted_hashes': "200"} + + +def test_ClassificationResult(): + "basic functionality of ClassificationResult dataclass" + qInf = QueryInfo(query_name='q1', query_md5='md5', query_filename='f1',query_bp='100', + query_n_hashes='10',ksize='31',scaled='10', total_weighted_hashes='200') + cr = ClassificationResult(rank="phylum", fraction=0.2, lineage=RankLineageInfo(lineage_str="a;b"), + f_weighted_at_rank=0.3, bp_match_at_rank=30, query_ani_at_rank=0.97) + cr.set_status(query_info=qInf, containment_threshold=0.1) + assert cr.status == 'match' + print(cr.query_ani_at_rank) + assert cr.query_ani_at_rank == approx(0.949, rel=1e-3) + cr.set_status(query_info=qInf, containment_threshold=0.35) + assert cr.status == 'below_threshold' + lD = cr.as_lineage_dict(ranks = RankLineageInfo().ranks, query_info=qInf) + print(lD) + assert lD == {'ident': 'q1', 'superkingdom': 'a', 'phylum': 'b', 'class': '', 'order': '', + 'family': '', 'genus': '', 'species': '', 'strain': ''} + + +def test_ClassificationResult_greater_than_1(): + "basic functionality of SummarizedGatherResult dataclass" + # fraction > 1 + with pytest.raises(ValueError) as exc: + ClassificationResult(rank="phylum", fraction=0.3, lineage=RankLineageInfo(lineage_str="a;b"), + f_weighted_at_rank=1.2, bp_match_at_rank=30) + print(str(exc)) + assert "> 100% of the query!" in str(exc) + # f_weighted > 1 + with pytest.raises(ValueError) as exc: + ClassificationResult(rank="phylum", fraction=1.2, lineage=RankLineageInfo(lineage_str="a;b"), + f_weighted_at_rank=0.3, bp_match_at_rank=30) + print(str(exc)) + assert "> 100% of the query!" in str(exc) + + +def test_ClassificationResult_0_fraction(): + with pytest.raises(ValueError) as exc: + ClassificationResult(rank="phylum", fraction=-.1, lineage=RankLineageInfo(lineage_str="a;b"), + f_weighted_at_rank=0.3, bp_match_at_rank=30) + err_msg = "Summarized fraction is <=0% of the query! This should not occur." + assert err_msg in str(exc) + #assert cr.status == 'nomatch' + + with pytest.raises(ValueError) as exc: + ClassificationResult(rank="phylum", fraction=.1, lineage=RankLineageInfo(lineage_str="a;b"), + f_weighted_at_rank=0, bp_match_at_rank=30) + print(str(exc)) + assert err_msg in str(exc) + + +def test_ClassificationResult_build_krona_result(): + qInf = QueryInfo(query_name='q1', query_md5='md5', query_filename='f1',query_bp='100', + query_n_hashes='10',ksize='31',scaled='10', total_weighted_hashes='200') + cr = ClassificationResult(rank="phylum", fraction=0.2, lineage=RankLineageInfo(lineage_str="a;b"), + f_weighted_at_rank=0.3, bp_match_at_rank=30, query_ani_at_rank=0.97) + #cr.set_status(query_info=qInf, rank='phylum') + kr, ukr = cr.build_krona_result(rank='phylum') + print(kr) + assert kr == (0.2, 'a', 'b') + print(ukr) + assert ukr == (0.8, 'unclassified', 'unclassified') + + +def test_ClassificationResult_build_krona_result_no_rank(): + qInf = QueryInfo(query_name='q1', query_md5='md5', query_filename='f1',query_bp='100', + query_n_hashes='10',ksize='31',scaled='10', total_weighted_hashes='200') + cr = ClassificationResult(rank="phylum", fraction=0.2, lineage=RankLineageInfo(lineage_str="a;b"), + f_weighted_at_rank=0.3, bp_match_at_rank=30, query_ani_at_rank=0.97) + cr.set_status(query_info=qInf, containment_threshold=0.1) + + +def test_GatherRow_old_gather(): + # gather does not contain query_name column + gA = {"name": "gA.1 name"} + with pytest.raises(TypeError) as exc: + make_GatherRow(gA, exclude_cols=['query_bp']) + print(str(exc)) + assert "__init__() missing 1 required positional argument: 'query_bp'" in str(exc) + + def test_get_ident_default(): ident = "GCF_001881345.1" n_id = get_ident(ident) assert n_id == "GCF_001881345" +def test_TaxResult_get_ident_default(): + gA = {"name": "GCF_001881345.1"} # gather result with match name as GCF_001881345.1 + taxres = make_TaxResult(gA) + print(taxres.match_ident) + assert taxres.match_ident == "GCF_001881345" + + +def test_AnnotateTaxResult_get_ident_default(): + gA = {"name": "GCF_001881345.1"} # gather result with match name as GCF_001881345.1 + taxres = AnnotateTaxResult(raw=gA) + print(taxres.match_ident) + assert taxres.match_ident == "GCF_001881345" + + +def test_AnnotateTaxResult_get_ident_idcol(): + gA = {"name": "n1", "match_name": "n2", "ident": "n3", "accession": "n4"} # gather result with match name as GCF_001881345.1 + taxres = AnnotateTaxResult(raw=gA) + print(taxres.match_ident) + assert taxres.match_ident == "n1" + taxres = AnnotateTaxResult(raw=gA, id_col="match_name") + print(taxres.match_ident) + assert taxres.match_ident == "n2" + taxres = AnnotateTaxResult(raw=gA, id_col="ident") + print(taxres.match_ident) + assert taxres.match_ident == "n3" + taxres = AnnotateTaxResult(raw=gA, id_col="accession") + print(taxres.match_ident) + assert taxres.match_ident == "n4" + + +def test_AnnotateTaxResult_get_ident_idcol_fail(): + gA = {"name": "n1", "match_name": "n2", "ident": "n3", "accession": "n4"} # gather result with match name as GCF_001881345.1 + with pytest.raises(ValueError) as exc: + AnnotateTaxResult(raw=gA, id_col="NotACol") + print(str(exc)) + assert "ID column 'NotACol' not found." in str(exc) + + def test_get_ident_split_but_keep_version(): - ident = "GCF_001881345.1" + ident = "GCF_001881345.1 secondname" n_id = get_ident(ident, keep_identifier_versions=True) assert n_id == "GCF_001881345.1" +def test_TaxResult_get_ident_split_but_keep_version(): + gA = {"name": "GCF_001881345.1 secondname"} + taxres = make_TaxResult(gA, keep_ident_version=True) + print("raw ident: ", taxres.raw.name) + print("keep_full?: ", taxres.keep_full_identifiers) + print("keep_version?: ",taxres.keep_identifier_versions) + print("final ident: ", taxres.match_ident) + assert taxres.match_ident == "GCF_001881345.1" + + +def test_AnnotateTaxResult_get_ident_split_but_keep_version(): + gA = {"name": "GCF_001881345.1 secondname"} + taxres = AnnotateTaxResult(gA, keep_identifier_versions=True) + print("raw ident: ", taxres.raw['name']) + print("keep_full?: ", taxres.keep_full_identifiers) + print("keep_version?: ",taxres.keep_identifier_versions) + print("final ident: ", taxres.match_ident) + assert taxres.match_ident == "GCF_001881345.1" + + def test_get_ident_no_split(): ident = "GCF_001881345.1 secondname" n_id = get_ident(ident, keep_full_identifiers=True) assert n_id == "GCF_001881345.1 secondname" +def test_TaxResult_get_ident_keep_full(): + gA = {"name": "GCF_001881345.1 secondname"} + taxres = make_TaxResult(gA, keep_full_ident=True) + print("raw ident: ", taxres.raw.name) + print("keep_full?: ", taxres.keep_full_identifiers) + print("keep_version?: ",taxres.keep_identifier_versions) + print("final ident: ", taxres.match_ident) + assert taxres.match_ident == "GCF_001881345.1 secondname" + + +def test_AnnotateTaxResult_get_ident_keep_full(): + gA = {"name": "GCF_001881345.1 secondname"} + taxres = AnnotateTaxResult(gA, keep_full_identifiers=True) + print("raw ident: ", taxres.raw['name']) + print("keep_full?: ", taxres.keep_full_identifiers) + print("keep_version?: ",taxres.keep_identifier_versions) + print("final ident: ", taxres.match_ident) + assert taxres.match_ident == "GCF_001881345.1 secondname" + + def test_collect_gather_csvs(runtmp): g_csv = utils.get_test_data('tax/test1.gather.csv') from_file = runtmp.output("tmp-from-file.txt") @@ -91,7 +531,7 @@ def test_check_and_load_gather_csvs_empty(runtmp): print(tax_assign) # check gather results and missing ids with pytest.raises(Exception) as exc: - gather_results, ids_missing, n_missing, header = check_and_load_gather_csvs(csvs, tax_assign) + check_and_load_gather_csvs(csvs, tax_assign) assert "Cannot read gather results from" in str(exc.value) @@ -117,12 +557,54 @@ def test_check_and_load_gather_csvs_with_empty_force(runtmp): keep_identifier_versions=False) print(tax_assign) # check gather results and missing ids - gather_results, ids_missing, n_missing, header = check_and_load_gather_csvs(csvs, tax_assign, force=True) - assert len(gather_results) == 4 - print("n_missing: ", n_missing) - print("ids_missing: ", ids_missing) - assert n_missing == 1 - assert ids_missing == {"gA"} + gather_results = check_and_load_gather_csvs(csvs, tax_assign, force=True) + assert len(gather_results) == 1 + q_res = gather_results[0] + assert len(q_res.raw_taxresults) == 4 + assert q_res.n_missed == 1 + assert 'gA' in q_res.missed_idents + assert q_res.n_skipped == 0 + + +def test_check_and_load_gather_lineage_csvs_empty(runtmp): + # try loading an empty annotated gather file + g_res = runtmp.output('empty.gather-tax.csv') + with open(g_res, 'w') as fp: + fp.write("") + + with pytest.raises(ValueError) as exc: + tax_assign = LineageDB.load_from_gather_with_lineages(g_res) + assert "cannot read taxonomy assignments" in str(exc.value) + + +def test_check_and_load_gather_lineage_csvs_bad_header(runtmp): + # test on file with wrong headers + g_res = runtmp.output('empty.gather-tax.csv') + with open(g_res, 'w', newline="") as fp: + fp.write("x,y,z") + + with pytest.raises(ValueError) as exc: + tax_assign = LineageDB.load_from_gather_with_lineages(g_res) + assert "Expected headers 'name' and 'lineage' not found. Is this a with-lineages file?" in str(exc.value) + + +def test_check_and_load_gather_lineage_csvs_dne(runtmp): + # test loading with-lineage file that does not exist + g_res = runtmp.output('empty.gather-tax.csv') + + with pytest.raises(ValueError) as exc: + tax_assign = LineageDB.load_from_gather_with_lineages(g_res) + assert "does not exist" in str(exc.value) + + +def test_check_and_load_gather_lineage_csvs_isdir(runtmp): + # test loading a with-lineage file that is actually a directory + g_res = runtmp.output('empty.gather-tax.csv') + os.mkdir(g_res) + + with pytest.raises(ValueError) as exc: + tax_assign = LineageDB.load_from_gather_with_lineages(g_res) + assert "is a directory" in str(exc.value) def test_check_and_load_gather_csvs_fail_on_missing(runtmp): @@ -142,17 +624,48 @@ def test_check_and_load_gather_csvs_fail_on_missing(runtmp): print(tax_assign) # check gather results and missing ids with pytest.raises(ValueError) as exc: - gather_results, ids_missing, n_missing, header = check_and_load_gather_csvs(csvs, tax_assign, fail_on_missing_taxonomy=True, force=True) - assert "Failing on missing taxonomy" in str(exc) + check_and_load_gather_csvs(csvs, tax_assign, fail_on_missing_taxonomy=True, force=True) + assert "Failing, as requested via --fail-on-missing-taxonomy" in str(exc) def test_load_gather_results(): + taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') + tax_assign = MultiLineageDB.load([taxonomy_csv], + keep_full_identifiers=False, + keep_identifier_versions=False) + gather_csv = utils.get_test_data('tax/test1.gather.csv') + gather_results, header = load_gather_results(gather_csv, tax_assignments=tax_assign) + assert len(gather_results) == 1 + for query_name, res in gather_results.items(): + assert query_name == 'test1' + assert len(res.raw_taxresults) == 4 + + +def test_load_gather_results_gzipped(runtmp): + gather_csv = utils.get_test_data('tax/test1.gather.csv') + taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') + tax_assign = MultiLineageDB.load([taxonomy_csv], + keep_full_identifiers=False, + keep_identifier_versions=False) gather_csv = utils.get_test_data('tax/test1.gather.csv') - gather_results, header, seen_queries = load_gather_results(gather_csv) - assert len(gather_results) == 4 + + # rewrite gather_csv as gzipped csv + gz_gather = runtmp.output('g.csv.gz') + with open(gather_csv, 'rb') as f_in, gzip.open(gz_gather, 'wb') as f_out: + f_out.writelines(f_in) + #gather_results, header, seen_queries = load_gather_results(gz_gather) + gather_results, header = load_gather_results(gz_gather, tax_assignments=tax_assign) + assert len(gather_results) == 1 + for query_name, res in gather_results.items(): + assert query_name == 'test1' + assert len(res.raw_taxresults) == 4 def test_load_gather_results_bad_header(runtmp): + taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') + tax_assign = MultiLineageDB.load([taxonomy_csv], + keep_full_identifiers=False, + keep_identifier_versions=False) g_csv = utils.get_test_data('tax/test1.gather.csv') bad_g_csv = runtmp.output('g.csv') @@ -165,11 +678,15 @@ def test_load_gather_results_bad_header(runtmp): print("bad_gather_results: \n", bad_g) with pytest.raises(ValueError) as exc: - gather_results, header = load_gather_results(bad_g_csv) - assert f'Not all required gather columns are present in {bad_g_csv}.' in str(exc.value) + gather_results, header = load_gather_results(bad_g_csv, tax_assignments=tax_assign) + assert f"'{bad_g_csv}' is missing columns needed for taxonomic summarization" in str(exc.value) def test_load_gather_results_empty(runtmp): + taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') + tax_assign = MultiLineageDB.load([taxonomy_csv], + keep_full_identifiers=False, + keep_identifier_versions=False) empty_csv = runtmp.output('g.csv') #creates empty gather result @@ -177,8 +694,8 @@ def test_load_gather_results_empty(runtmp): fp.write('') with pytest.raises(ValueError) as exc: - gather_results, header = load_gather_results(empty_csv) - assert f'Cannot read gather results from {empty_csv}. Is file empty?' in str(exc.value) + gather_results, header = load_gather_results(empty_csv, tax_assignments=tax_assign) + assert f"Cannot read gather results from '{empty_csv}'. Is file empty?" in str(exc.value) def test_load_taxonomy_csv(): @@ -189,6 +706,59 @@ def test_load_taxonomy_csv(): assert len(tax_assign) == 6 # should have read 6 rows +def test_load_taxonomy_csv_LIN(): + taxonomy_csv = utils.get_test_data('tax/test.LIN-taxonomy.csv') + tax_assign = MultiLineageDB.load([taxonomy_csv], lins=True) + print("taxonomy assignments: \n", tax_assign) + assert list(tax_assign.keys()) == ['GCF_001881345.1', 'GCF_009494285.1', 'GCF_013368705.1', 'GCF_003471795.1', 'GCF_000017325.1', 'GCF_000021665.1'] + #assert list(tax_assign.keys()) == ["GCF_000010525.1", "GCF_000007365.1", "GCF_000007725.1", "GCF_000009605.1", "GCF_000021065.1", "GCF_000021085.1"] + assert len(tax_assign) == 6 # should have read 6 rows + print(tax_assign.available_ranks) + assert tax_assign.available_ranks == {str(x) for x in range(0,20)} + + +def test_load_taxonomy_csv_LIN_fail(): + taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') + with pytest.raises(ValueError) as exc: + MultiLineageDB.load([taxonomy_csv], lins=True) + assert f"'lin' column not found: cannot read LIN taxonomy assignments from {taxonomy_csv}." in str(exc.value) + + +def test_load_taxonomy_csv_LIN_mismatch_in_taxfile(runtmp): + taxonomy_csv = utils.get_test_data('tax/test.LIN-taxonomy.csv') + mimatchLIN_csv = runtmp.output('mmLIN-taxonomy.csv') + with open(mimatchLIN_csv, 'w') as mm: + tax21=[] + tax = [x.rstrip() for x in open(taxonomy_csv, 'r')] + for n, taxline in enumerate(tax): + if n == 2: # add ;0 to a LIN + taxlist = taxline.split(',') + taxlist[1] += ';0' # add 21st position to LIN + tax21.append(",".join(taxlist)) + else: + tax21.append(taxline) + mm.write("\n".join(tax21)) + with pytest.raises(ValueError) as exc: + MultiLineageDB.load([mimatchLIN_csv], lins=True) + assert "For taxonomic summarization, all LIN assignments must use the same number of LIN positions." in str(exc.value) + + +def test_load_taxonomy_csv_gzip(runtmp): + # test loading a gzipped taxonomy csv file + taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') + tax_gz = runtmp.output('tax.csv.gz') + + with gzip.open(tax_gz, 'wt') as outfp: + with open(taxonomy_csv, 'rt') as infp: + data = infp.read() + outfp.write(data) + + tax_assign = MultiLineageDB.load([tax_gz]) + print("taxonomy assignments: \n", tax_assign) + assert list(tax_assign.keys()) == ['GCF_001881345.1', 'GCF_009494285.1', 'GCF_013368705.1', 'GCF_003471795.1', 'GCF_000017325.1', 'GCF_000021665.1'] + assert len(tax_assign) == 6 # should have read 6 rows + + def test_load_taxonomy_csv_split_id(): taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') tax_assign = MultiLineageDB.load([taxonomy_csv], keep_full_identifiers=0, @@ -269,540 +839,91 @@ def test_load_taxonomy_csv_duplicate_force(runtmp): assert list(tax_assign.keys()) == ['GCF_001881345.1', 'GCF_009494285.1', 'GCF_013368705.1', 'GCF_003471795.1', 'GCF_000017325.1', 'GCF_000021665.1'] -def test_find_missing_identities(): - # make gather results - gA = ["queryA", "gA","0.5","0.5", "queryA_md5", "queryA.sig", '0.5', '50', '50'] - gB = ["queryA", "gB","0.3","0.5", "queryA_md5", "queryA.sig", '0.5', '50', '50'] - g_res = make_mini_gather_results([gA,gB]) - - # make mini taxonomy - gA_tax = ("gA", "a;b;c") - taxD = make_mini_taxonomy([gA_tax]) - - n, ids = find_missing_identities(g_res, taxD) - print("n_missing: ", n) - print("ids_missing: ", ids) - assert n == 1 - assert ids == {"gB"} - - -def test_summarize_gather_at_0(): - """test two matches, equal f_unique_to_query""" +def test_format_for_krona_summarization(): + """test format for krona""" # make gather results - gA = ["queryA", "gA","0.5","0.5", "queryA_md5", "queryA.sig", '0.5', '50', '50'] - gB = ["queryA", "gB","0.3","0.5", "queryA_md5", "queryA.sig", '0.5', '50', '50'] - g_res = make_mini_gather_results([gA,gB]) - - # make mini taxonomy - gA_tax = ("gA", "a;b;c") - gB_tax = ("gB", "a;b;d") - taxD = make_mini_taxonomy([gA_tax,gB_tax]) - - # run summarize_gather_at and check results! - sk_sum, _ = summarize_gather_at("superkingdom", taxD, g_res) - - # superkingdom - assert len(sk_sum) == 1 - print("superkingdom summarized gather: ", sk_sum[0]) - assert sk_sum[0].query_name == "queryA" - assert sk_sum[0].query_md5 == "queryA_md5" - assert sk_sum[0].query_filename == "queryA.sig" - assert sk_sum[0].rank == 'superkingdom' - assert sk_sum[0].lineage == (LineagePair(rank='superkingdom', name='a'),) - assert sk_sum[0].fraction == 1.0 - assert sk_sum[0].f_weighted_at_rank == 1.0 - assert sk_sum[0].bp_match_at_rank == 100 - - # phylum - phy_sum, _ = summarize_gather_at("phylum", taxD, g_res) - print("phylum summarized gather: ", phy_sum[0]) - assert len(phy_sum) == 1 - assert phy_sum[0].query_name == "queryA" - assert phy_sum[0].query_md5 == "queryA_md5" - assert phy_sum[0].query_filename == "queryA.sig" - assert phy_sum[0].rank == 'phylum' - assert phy_sum[0].lineage == (LineagePair(rank='superkingdom', name='a'),LineagePair(rank='phylum', name='b')) - assert phy_sum[0].fraction == 1.0 - assert phy_sum[0].f_weighted_at_rank == 1.0 - assert phy_sum[0].bp_match_at_rank == 100 - # class - cl_sum, _ = summarize_gather_at("class", taxD, g_res) - assert len(cl_sum) == 2 - print("class summarized gather: ", cl_sum) - assert cl_sum[0].query_name == "queryA" - assert cl_sum[0].query_md5 == "queryA_md5" - assert cl_sum[0].query_filename == "queryA.sig" - assert cl_sum[0].rank == 'class' - assert cl_sum[0].lineage == (LineagePair(rank='superkingdom', name='a'), - LineagePair(rank='phylum', name='b'), - LineagePair(rank='class', name='c')) - assert cl_sum[0].fraction == 0.5 - assert cl_sum[0].f_weighted_at_rank == 0.5 - assert cl_sum[0].bp_match_at_rank == 50 - assert cl_sum[1].rank == 'class' - assert cl_sum[1].lineage == (LineagePair(rank='superkingdom', name='a'), - LineagePair(rank='phylum', name='b'), - LineagePair(rank='class', name='d')) - assert cl_sum[1].fraction == 0.5 - assert cl_sum[1].f_weighted_at_rank == 0.5 - assert cl_sum[1].bp_match_at_rank == 50 - - -def test_summarize_gather_at_1(): - """test two matches, diff f_unique_to_query""" - # make mini gather_results - gA = ["queryA", "gA","0.5","0.6", "queryA_md5", "queryA.sig", '0.5', '60', '40'] - gB = ["queryA", "gB","0.3","0.1", "queryA_md5", "queryA.sig", '0.1', '10', '90'] - g_res = make_mini_gather_results([gA,gB]) - - # make mini taxonomy - gA_tax = ("gA", "a;b;c") - gB_tax = ("gB", "a;b;d") - taxD = make_mini_taxonomy([gA_tax,gB_tax]) - # run summarize_gather_at and check results! - sk_sum, _ = summarize_gather_at("superkingdom", taxD, g_res) - - # superkingdom - assert len(sk_sum) == 2 - print("superkingdom summarized gather: ", sk_sum[0]) - assert sk_sum[0].lineage == (LineagePair(rank='superkingdom', name='a'),) - assert sk_sum[0].fraction == 0.7 - assert sk_sum[0].bp_match_at_rank == 70 - assert sk_sum[1].lineage == () - assert round(sk_sum[1].fraction, 1) == 0.3 - assert sk_sum[1].bp_match_at_rank == 30 - - # phylum - phy_sum, _ = summarize_gather_at("phylum", taxD, g_res) - print("phylum summarized gather: ", phy_sum[0]) - assert len(phy_sum) == 2 - assert phy_sum[0].lineage == (LineagePair(rank='superkingdom', name='a'),LineagePair(rank='phylum', name='b')) - assert phy_sum[0].fraction == 0.7 - assert phy_sum[0].f_weighted_at_rank == 0.6 - assert phy_sum[0].bp_match_at_rank == 70 - assert phy_sum[1].lineage == () - assert round(phy_sum[1].fraction, 1) == 0.3 - assert phy_sum[1].bp_match_at_rank == 30 - # class - cl_sum, _ = summarize_gather_at("class", taxD, g_res) - assert len(cl_sum) == 3 - print("class summarized gather: ", cl_sum) - assert cl_sum[0].lineage == (LineagePair(rank='superkingdom', name='a'), - LineagePair(rank='phylum', name='b'), - LineagePair(rank='class', name='c')) - assert cl_sum[0].fraction == 0.6 - assert cl_sum[0].f_weighted_at_rank == 0.5 - assert cl_sum[0].bp_match_at_rank == 60 - - assert cl_sum[1].rank == 'class' - assert cl_sum[1].lineage == (LineagePair(rank='superkingdom', name='a'), - LineagePair(rank='phylum', name='b'), - LineagePair(rank='class', name='d')) - assert cl_sum[1].fraction == 0.1 - assert cl_sum[1].f_weighted_at_rank == 0.1 - assert cl_sum[1].bp_match_at_rank == 10 - assert cl_sum[2].lineage == () - assert round(cl_sum[2].fraction, 1) == 0.3 - - -def test_summarize_gather_at_perfect_match(): - """test 100% gather match (f_unique_to_query == 1)""" - # make mini gather_results - gA = ["queryA", "gA","0.5","1.0", "queryA_md5", "queryA.sig", '0.5', '100', '0'] - gB = ["queryA", "gB","0.3","0.0", "queryA_md5", "queryA.sig", '0.5', '0', '100'] - g_res = make_mini_gather_results([gA,gB]) - - # make mini taxonomy - gA_tax = ("gA", "a;b;c") - gB_tax = ("gB", "a;b;d") - taxD = make_mini_taxonomy([gA_tax,gB_tax]) - - # run summarize_gather_at and check results! - sk_sum, _ = summarize_gather_at("superkingdom", taxD, g_res) - # superkingdom - assert len(sk_sum) == 1 - print("superkingdom summarized gather: ", sk_sum[0]) - assert sk_sum[0].lineage == (LineagePair(rank='superkingdom', name='a'),) - assert sk_sum[0].fraction == 1.0 - - -def test_summarize_gather_at_over100percent_f_unique_to_query(): - """gather matches that add up to >100% f_unique_to_query""" - # make mini gather_results - gA = ["queryA", "gA","0.5","0.5", "queryA_md5", "queryA.sig", '0.5', '50', '50'] - gB = ["queryA", "gB","0.3","0.6", "queryA_md5", "queryA.sig", '0.5', '60', '40'] - g_res = make_mini_gather_results([gA,gB]) - - # make mini taxonomy - gA_tax = ("gA", "a;b;c") - gB_tax = ("gB", "a;b;d") + # make mini taxonomy + gA_tax = ("gA", "a;b") + gB_tax = ("gB", "a;c") taxD = make_mini_taxonomy([gA_tax,gB_tax]) - # run summarize_gather_at and check results! - with pytest.raises(ValueError) as exc: - sk_sum, _ = summarize_gather_at("superkingdom", taxD, g_res) - assert "The tax summary of query 'queryA' is 1.1, which is > 100% of the query!!" in str(exc) - - # phylum - with pytest.raises(ValueError) as exc: - phy_sum, _ = summarize_gather_at("phylum", taxD, g_res) - assert "The tax summary of query 'queryA' is 1.1, which is > 100% of the query!!" in str(exc) - - # class - cl_sum, _ = summarize_gather_at("class", taxD, g_res) - assert len(cl_sum) == 2 - print("class summarized gather: ", cl_sum) - assert cl_sum[0].lineage == (LineagePair(rank='superkingdom', name='a'), - LineagePair(rank='phylum', name='b'), - LineagePair(rank='class', name='d')) - assert cl_sum[0].fraction == 0.6 - assert cl_sum[0].bp_match_at_rank == 60 - assert cl_sum[1].rank == 'class' - assert cl_sum[1].lineage == (LineagePair(rank='superkingdom', name='a'), - LineagePair(rank='phylum', name='b'), - LineagePair(rank='class', name='c')) - assert cl_sum[1].fraction == 0.5 - assert cl_sum[1].bp_match_at_rank == 50 - - -def test_summarize_gather_at_missing_ignore(): - """test two matches, ignore missing taxonomy""" - # make gather results - gA = ["queryA", "gA","0.5","0.5", "queryA_md5", "queryA.sig", '0.5', '50', '50'] - gB = ["queryA", "gB","0.3","0.5", "queryA_md5", "queryA.sig", '0.5', '50', '50'] - g_res = make_mini_gather_results([gA,gB]) + gather_results = [{'query_name': 'queryA', 'name': 'gA', 'f_unique_weighted': 0.2,'f_unique_to_query': 0.2,'unique_intersect_bp': 50}, + {'query_name': 'queryA', "name": 'gB', 'f_unique_weighted': 0.3,'f_unique_to_query': 0.3,'unique_intersect_bp': 30}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, summarize=True, single_query=True) + kres, header = format_for_krona([q_res], 'superkingdom') + assert header == ['fraction', 'superkingdom'] + print("krona_res: ", kres) + assert kres == [(0.5, 'a'), (0.5, 'unclassified')] + kres, header = format_for_krona([q_res], 'phylum') + assert header == ['fraction', 'superkingdom', 'phylum'] + assert kres == [(0.3, 'a', 'c'), (0.2, 'a', 'b'), (0.5, 'unclassified', 'unclassified')] - # make mini taxonomy - gA_tax = ("gA", "a;b;c") - taxD = make_mini_taxonomy([gA_tax]) - # run summarize_gather_at and check results! - sk_sum, _ = summarize_gather_at("superkingdom", taxD, g_res, skip_idents=['gB']) - # superkingdom - assert len(sk_sum) == 2 - print("superkingdom summarized gather: ", sk_sum[0]) - assert sk_sum[0].lineage == (LineagePair(rank='superkingdom', name='a'),) - assert sk_sum[0].fraction == 0.5 - assert sk_sum[0].bp_match_at_rank == 50 - assert sk_sum[1].lineage == () - assert sk_sum[1].fraction == 0.5 - assert sk_sum[1].bp_match_at_rank == 50 - - # phylum - phy_sum, _ = summarize_gather_at("phylum", taxD, g_res, skip_idents=['gB']) - print("phylum summarized gather: ", phy_sum[0]) - assert len(phy_sum) == 2 - assert phy_sum[0].lineage == (LineagePair(rank='superkingdom', name='a'),LineagePair(rank='phylum', name='b')) - assert phy_sum[0].fraction == 0.5 - assert phy_sum[0].bp_match_at_rank == 50 - assert phy_sum[1].lineage == () - assert phy_sum[1].fraction == 0.5 - assert phy_sum[1].bp_match_at_rank == 50 - # class - cl_sum, _ = summarize_gather_at("class", taxD, g_res, skip_idents=['gB']) - assert len(cl_sum) == 2 - print("class summarized gather: ", cl_sum) - assert cl_sum[0].lineage == (LineagePair(rank='superkingdom', name='a'), - LineagePair(rank='phylum', name='b'), - LineagePair(rank='class', name='c')) - assert cl_sum[0].fraction == 0.5 - assert cl_sum[0].bp_match_at_rank == 50 - assert cl_sum[1].lineage == () - assert cl_sum[1].fraction == 0.5 - assert cl_sum[1].bp_match_at_rank == 50 - - -def test_summarize_gather_at_missing_fail(): - """test two matches, fail on missing taxonomy""" +def test_format_for_krona_classification(): + """test format for krona""" # make gather results - gA = ["queryA", "gA","0.5","0.5", "queryA_md5", "queryA.sig", '0.5', '50', '50'] - gB = ["queryA", "gB","0.3","0.5", "queryA_md5", "queryA.sig", '0.5', '50', '50'] - g_res = make_mini_gather_results([gA,gB]) - - # make mini taxonomy - gA_tax = ("gA", "a;b;c") - taxD = make_mini_taxonomy([gA_tax]) - - # run summarize_gather_at and check results! - with pytest.raises(ValueError) as exc: - sk_sum, _ = summarize_gather_at("superkingdom", taxD, g_res) - assert "ident gB is not in the taxonomy database." in str(exc.value) - - -def test_summarize_gather_at_best_only_0(): - """test two matches, diff f_unique_to_query""" - # make mini gather_results - gA = ["queryA", "gA","0.5","0.6", "queryA_md5", "queryA.sig", '0.5', '60', '40'] - gB = ["queryA", "gB","0.3","0.1", "queryA_md5", "queryA.sig", '0.5', '10', '90'] - g_res = make_mini_gather_results([gA,gB]) - - # make mini taxonomy - gA_tax = ("gA", "a;b;c") - gB_tax = ("gB", "a;b;d") - taxD = make_mini_taxonomy([gA_tax,gB_tax]) - # run summarize_gather_at and check results! - sk_sum, _ = summarize_gather_at("superkingdom", taxD, g_res, best_only=True) - # superkingdom - assert len(sk_sum) == 1 - print("superkingdom summarized gather: ", sk_sum[0]) - assert sk_sum[0].lineage == (LineagePair(rank='superkingdom', name='a'),) - assert sk_sum[0].fraction == 0.7 - assert sk_sum[0].bp_match_at_rank == 70 - - # phylum - phy_sum, _ = summarize_gather_at("phylum", taxD, g_res, best_only=True) - print("phylum summarized gather: ", phy_sum[0]) - assert len(phy_sum) == 1 - assert phy_sum[0].lineage == (LineagePair(rank='superkingdom', name='a'),LineagePair(rank='phylum', name='b')) - assert phy_sum[0].fraction == 0.7 - assert phy_sum[0].bp_match_at_rank == 70 - # class - cl_sum, _ = summarize_gather_at("class", taxD, g_res, best_only=True) - assert len(cl_sum) == 1 - print("class summarized gather: ", cl_sum) - assert cl_sum[0].lineage == (LineagePair(rank='superkingdom', name='a'), - LineagePair(rank='phylum', name='b'), - LineagePair(rank='class', name='c')) - assert cl_sum[0].fraction == 0.6 - assert cl_sum[0].bp_match_at_rank == 60 - - -def test_summarize_gather_at_best_only_equal_choose_first(): - """test two matches, equal f_unique_to_query. best_only chooses first""" - # make mini gather_results - gA = ["queryA", "gA","0.5","0.5", "queryA_md5", "queryA.sig", '0.5', '50', '50'] - gB = ["queryA", "gB","0.3","0.5", "queryA_md5", "queryA.sig", '0.5', '50', '50'] - g_res = make_mini_gather_results([gA,gB]) - - # make mini taxonomy - gA_tax = ("gA", "a;b;c") - gB_tax = ("gB", "a;b;d") + # make mini taxonomy + gA_tax = ("gA", "a;b") + gB_tax = ("gB", "a;c") taxD = make_mini_taxonomy([gA_tax,gB_tax]) - # run summarize_gather_at and check results! - # class - cl_sum, _ = summarize_gather_at("class", taxD, g_res, best_only=True) - assert len(cl_sum) == 1 - print("class summarized gather: ", cl_sum) - assert cl_sum[0].lineage == (LineagePair(rank='superkingdom', name='a'), - LineagePair(rank='phylum', name='b'), - LineagePair(rank='class', name='c')) - assert cl_sum[0].fraction == 0.5 - assert cl_sum[0].bp_match_at_rank == 50 - - -def test_write_summary_csv(runtmp): - """test summary csv write function""" - - sum_gather = {'superkingdom': [SummarizedGatherResult(query_name='queryA', rank='superkingdom', fraction=1.0, - query_md5='queryA_md5', query_filename='queryA.sig', - f_weighted_at_rank=1.0, bp_match_at_rank=100, - lineage=(LineagePair(rank='superkingdom', name='a'),))], - 'phylum': [SummarizedGatherResult(query_name='queryA', rank='phylum', fraction=1.0, - query_md5='queryA_md5', query_filename='queryA.sig', - f_weighted_at_rank=1.0, bp_match_at_rank=100, - lineage=(LineagePair(rank='superkingdom', name='a'), - LineagePair(rank='phylum', name='b')))]} - - outs= runtmp.output("outsum.csv") - with open(outs, 'w') as out_fp: - write_summary(sum_gather, out_fp) - - sr = [x.rstrip().split(',') for x in open(outs, 'r')] - print("gather_summary_results_from_file: \n", sr) - assert ['query_name', 'rank', 'fraction', 'lineage', 'query_md5', 'query_filename', 'f_weighted_at_rank', 'bp_match_at_rank'] == sr[0] - assert ['queryA', 'superkingdom', '1.0', 'a', 'queryA_md5', 'queryA.sig', '1.0', '100'] == sr[1] - assert ['queryA', 'phylum', '1.0', 'a;b', 'queryA_md5', 'queryA.sig', '1.0', '100'] == sr[2] - - -def test_write_classification(runtmp): - """test classification csv write function""" - classif = ClassificationResult('queryA', 'match', 'phylum', 1.0, - (LineagePair(rank='superkingdom', name='a'), - LineagePair(rank='phylum', name='b')), - 'queryA_md5', 'queryA.sig', 1.0, 100) - - classification = {'phylum': [classif]} - - outs= runtmp.output("outsum.csv") - with open(outs, 'w') as out_fp: - write_classifications(classification, out_fp) - - sr = [x.rstrip().split(',') for x in open(outs, 'r')] - print("gather_classification_results_from_file: \n", sr) - assert ['query_name', 'status', 'rank', 'fraction', 'lineage', 'query_md5', 'query_filename', 'f_weighted_at_rank', 'bp_match_at_rank'] == sr[0] - assert ['queryA', 'match', 'phylum', '1.0', 'a;b', 'queryA_md5', 'queryA.sig', '1.0', '100'] == sr[1] - - -def test_make_krona_header_0(): - hd = make_krona_header("species") - print("header: ", hd) - assert hd == ("fraction", "superkingdom", "phylum", "class", "order", "family", "genus", "species") - - -def test_make_krona_header_1(): - hd = make_krona_header("order") - print("header: ", hd) - assert hd == ("fraction", "superkingdom", "phylum", "class", "order") - - -def test_make_krona_header_strain(): - hd = make_krona_header("strain", include_strain=True) - print("header: ", hd) - assert hd == ("fraction", "superkingdom", "phylum", "class", "order", "family", "genus", "species", "strain") - + gather_results = [{'query_name': 'queryA', 'name': 'gA', 'f_unique_weighted': 0.2,'f_unique_to_query': 0.2,'unique_intersect_bp': 50}, + {'query_name': 'queryA', "name": 'gB', 'f_unique_weighted': 0.3,'f_unique_to_query': 0.3,'unique_intersect_bp': 30}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, classify=True, single_query=True) + kres, header = format_for_krona([q_res], 'superkingdom', classification=True) + assert header == ['fraction', 'superkingdom'] + print("krona_res: ", kres) + assert kres == [(0.5, 'a')]#, (0.5, 'unclassified')] + kres, header = format_for_krona([q_res], 'phylum', classification=True) + assert header == ['fraction', 'superkingdom', 'phylum'] + assert kres == [(0.3, 'a', 'c')]#, (0.7, 'unclassified', 'unclassified')] -def test_make_krona_header_fail(): - with pytest.raises(ValueError) as exc: - make_krona_header("strain") - assert "Rank strain not present in available ranks" in str(exc.value) - -def test_aggregate_by_lineage_at_rank_by_query(): - """test two queries, aggregate lineage at rank for each""" +def test_format_for_krona_improper_rank(): + """test format for krona""" # make gather results - gA = ["queryA","gA","0.5","0.5", "queryA_md5", "queryA.sig", '0.5', '100', '100'] - gB = ["queryA","gB","0.3","0.4", "queryA_md5", "queryA.sig", '0.5', '60', '140'] - gC = ["queryB","gB","0.3","0.3", "queryB_md5", "queryB.sig", '0.5', '60', '140'] - g_res = make_mini_gather_results([gA,gB,gC]) - - # make mini taxonomy + # make mini taxonomy gA_tax = ("gA", "a;b") gB_tax = ("gB", "a;c") taxD = make_mini_taxonomy([gA_tax,gB_tax]) - # aggregate by lineage at rank - sk_sum, _ = summarize_gather_at("superkingdom", taxD, g_res) - print("superkingdom summarized gather results:", sk_sum) - assert len(sk_sum) ==4 - assert sk_sum[0].query_name == "queryA" - assert sk_sum[0].lineage == (LineagePair(rank='superkingdom', name='a'),) - assert sk_sum[0].fraction == 0.9 - assert sk_sum[0].bp_match_at_rank == 160 - # check for unassigned for queryA - assert sk_sum[1].query_name == "queryA" - assert sk_sum[1].lineage == () - assert sk_sum[1].bp_match_at_rank == 40 - assert round(sk_sum[1].fraction,1) == 0.1 - # queryB - assert sk_sum[2].query_name == "queryB" - assert sk_sum[2].lineage == (LineagePair(rank='superkingdom', name='a'),) - assert sk_sum[2].fraction == 0.3 - assert sk_sum[2].bp_match_at_rank == 60 - # check for unassigned for queryA - assert sk_sum[3].query_name == "queryB" - assert sk_sum[3].lineage == () - assert sk_sum[3].fraction == 0.7 - assert sk_sum[3].bp_match_at_rank == 140 - sk_lin_sum, query_names, num_queries = aggregate_by_lineage_at_rank(sk_sum, by_query=True) - print("superkingdom lineage summary:", sk_lin_sum, '\n') - assert sk_lin_sum == {(LineagePair(rank='superkingdom', name='a'),): {'queryA': 0.9, 'queryB': 0.3}, - (): {'queryA': 0.09999999999999998, 'queryB': 0.7}} - assert num_queries == 2 - assert query_names == ['queryA', 'queryB'] - - phy_sum, _ = summarize_gather_at("phylum", taxD, g_res) - print("phylum summary:", phy_sum, ']\n') - phy_lin_sum, query_names, num_queries = aggregate_by_lineage_at_rank(phy_sum, by_query=True) - print("phylum lineage summary:", phy_lin_sum, '\n') - assert phy_lin_sum == {(LineagePair(rank='superkingdom', name='a'), LineagePair(rank='phylum', name='b')): {'queryA': 0.5}, - (LineagePair(rank='superkingdom', name='a'), LineagePair(rank='phylum', name='c')): {'queryA': 0.4, 'queryB': 0.3}, - (): {'queryA': 0.09999999999999998, 'queryB': 0.7}} - assert num_queries == 2 - assert query_names == ['queryA', 'queryB'] - - -def test_format_for_krona_0(): - """test format for krona, equal matches""" - # make gather results - gA = ["queryA", "gA","0.5","0.5", "queryA_md5", "queryA.sig", '0.5', '50', '50'] - gB = ["queryA", "gB","0.3","0.5", "queryA_md5", "queryA.sig", '0.5', '50', '50'] - g_res = make_mini_gather_results([gA,gB]) - - # make mini taxonomy - gA_tax = ("gA", "a;b;c") - gB_tax = ("gB", "a;b;d") - taxD = make_mini_taxonomy([gA_tax,gB_tax]) - - # check krona format and check results! - sk_sum, _ = summarize_gather_at("superkingdom", taxD, g_res) - print("superkingdom summarized gather results:", sk_sum) - krona_res = format_for_krona("superkingdom", {"superkingdom": sk_sum}) - print("krona_res: ", krona_res) - assert krona_res == [(1.0, 'a')] - - phy_sum, _ = summarize_gather_at("phylum", taxD, g_res) - krona_res = format_for_krona("phylum", {"phylum": phy_sum}) - print("krona_res: ", krona_res) - assert krona_res == [(1.0, 'a', 'b')] - - -def test_format_for_krona_1(): - """test format for krona at each rank""" - # make gather results - gA = ["queryA", "gA","0.5","0.5", "queryA_md5", "queryA.sig", '0.5', '50', '50'] - gB = ["queryA", "gB","0.3","0.5", "queryA_md5", "queryA.sig", '0.5', '50', '50'] - g_res = make_mini_gather_results([gA,gB]) + gather_results = [{'query_name': 'queryA', 'name': 'gA', 'f_unique_weighted': 0.2,'f_unique_to_query': 0.2,'unique_intersect_bp': 50}, + {'query_name': 'queryA', "name": 'gB', 'f_unique_weighted': 0.3,'f_unique_to_query': 0.3,'unique_intersect_bp': 30}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, summarize=True, single_query=True) + with pytest.raises(ValueError) as exc: + format_for_krona([q_res], 'NotARank') + print(str(exc)) + assert "Rank 'NotARank' not present in summarized ranks." in str(exc) - # make mini taxonomy - gA_tax = ("gA", "a;b;c") - gB_tax = ("gB", "a;b;d") - taxD = make_mini_taxonomy([gA_tax,gB_tax]) - # summarize with all ranks - sum_res = {} - #for rank in lca_utils.taxlist(include_strain=False): - for rank in ['superkingdom', 'phylum', 'class']: - sum_res[rank], _ = summarize_gather_at(rank, taxD, g_res) - print('summarized gather: ', sum_res) - # check krona format - sk_krona = format_for_krona("superkingdom", sum_res) - print("sk_krona: ", sk_krona) - assert sk_krona == [(1.0, 'a')] - phy_krona = format_for_krona("phylum", sum_res) - print("phy_krona: ", phy_krona) - assert phy_krona == [(1.0, 'a', 'b')] - cl_krona = format_for_krona("class", sum_res) - print("cl_krona: ", cl_krona) - assert cl_krona == [(0.5, 'a', 'b', 'c'), (0.5, 'a', 'b', 'd')] - - -def test_format_for_krona_best_only(): - """test two matches, equal f_unique_to_query""" +def test_format_for_krona_summarization_two_queries(): + """test format for krona with multiple queries (normalize by n_queries)""" # make gather results - gA = ["queryA", "gA","0.5","0.5", "queryA_md5", "queryA.sig", '0.5', '50', '50'] - gB = ["queryA", "gB","0.3","0.5", "queryA_md5", "queryA.sig", '0.5', '50', '50'] - g_res = make_mini_gather_results([gA,gB]) - - # make mini taxonomy - gA_tax = ("gA", "a;b;c") - gB_tax = ("gB", "a;b;d") + # make mini taxonomy + gA_tax = ("gA", "a;b") + gB_tax = ("gB", "a;c") taxD = make_mini_taxonomy([gA_tax,gB_tax]) - # summarize with all ranks - sum_res = {} - #for rank in lca_utils.taxlist(include_strain=False): - for rank in ['superkingdom', 'phylum', 'class']: - sum_res[rank], _ = summarize_gather_at(rank, taxD, g_res, best_only=True) - print('summarized gather: ', sum_res) - # check krona format - sk_krona = format_for_krona("superkingdom", sum_res) - print("sk_krona: ", sk_krona) - assert sk_krona == [(1.0, 'a')] - phy_krona = format_for_krona("phylum", sum_res) - print("phy_krona: ", phy_krona) - assert phy_krona == [(1.0, 'a', 'b')] - cl_krona = format_for_krona("class", sum_res) - print("cl_krona: ", cl_krona) - assert cl_krona == [(0.5, 'a', 'b', 'c')] + gather_results = [{'query_name': 'queryA', 'name': 'gA', 'f_unique_weighted': 0.2,'f_unique_to_query': 0.2,'unique_intersect_bp': 50}, + {'query_name': 'queryA', "name": 'gB', 'f_unique_weighted': 0.3,'f_unique_to_query': 0.3,'unique_intersect_bp': 30}, + {'query_name': 'queryB', "name": 'gB', 'f_unique_weighted': 0.5,'f_unique_to_query': 0.5,'unique_intersect_bp': 50}] + gres = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, summarize=True) + kres, header = format_for_krona(list(gres.values()), 'superkingdom') + assert header == ['fraction', 'superkingdom'] + print("krona_res: ", kres) + assert kres == [(0.5, 'a'), (0.5, 'unclassified')] + kres, header = format_for_krona(list(gres.values()), 'phylum') + assert header == ['fraction', 'superkingdom', 'phylum'] + assert kres == [(0.4, 'a', 'c'), (0.1, 'a', 'b'), (0.5, 'unclassified', 'unclassified')] def test_write_krona(runtmp): """test two matches, equal f_unique_to_query""" - class_krona_results = [(0.5, 'a', 'b', 'c'), (0.5, 'a', 'b', 'd')] + krona_results = [(0.5, 'a', 'b', 'c'), (0.5, 'a', 'b', 'd')] + header = ['fraction', 'superkingdom', 'phylum', 'class'] outk= runtmp.output("outkrona.tsv") with open(outk, 'w') as out_fp: - write_krona("class", class_krona_results, out_fp) + write_krona(header, krona_results, out_fp) kr = [x.strip().split('\t') for x in open(outk, 'r')] print("krona_results_from_file: \n", kr) @@ -811,47 +932,6 @@ def test_write_krona(runtmp): assert kr[2] == ["0.5", "a", "b", "d"] -def test_combine_sumgather_csvs_by_lineage(runtmp): - # some summarized gather dicts - sum_gather1 = {'superkingdom': [SummarizedGatherResult(query_name='queryA', rank='superkingdom', fraction=0.5, - query_md5='queryA_md5', query_filename='queryA.sig', - f_weighted_at_rank=1.0, bp_match_at_rank=100, - lineage=(LineagePair(rank='superkingdom', name='a'),))], - 'phylum': [SummarizedGatherResult(query_name='queryA', rank='phylum', fraction=0.5, - query_md5='queryA_md5', query_filename='queryA.sig', - f_weighted_at_rank=0.5, bp_match_at_rank=50, - lineage=(LineagePair(rank='superkingdom', name='a'), - LineagePair(rank='phylum', name='b')))]} - sum_gather2 = {'superkingdom': [SummarizedGatherResult(query_name='queryB', rank='superkingdom', fraction=0.7, - query_md5='queryB_md5', query_filename='queryB.sig', - f_weighted_at_rank=0.7, bp_match_at_rank=70, - lineage=(LineagePair(rank='superkingdom', name='a'),))], - 'phylum': [SummarizedGatherResult(query_name='queryB', rank='phylum', fraction=0.7, - query_md5='queryB_md5', query_filename='queryB.sig', - f_weighted_at_rank=0.7, bp_match_at_rank=70, - lineage=(LineagePair(rank='superkingdom', name='a'), - LineagePair(rank='phylum', name='c')))]} - - # write summarized gather results csvs - sg1= runtmp.output("sample1.csv") - with open(sg1, 'w') as out_fp: - write_summary(sum_gather1, out_fp) - - sg2= runtmp.output("sample2.csv") - with open(sg2, 'w') as out_fp: - write_summary(sum_gather2, out_fp) - - # test combine_summarized_gather_csvs_by_lineage_at_rank - linD, query_names = combine_sumgather_csvs_by_lineage([sg1,sg2], rank="phylum") - print("lineage_dict", linD) - assert linD == {'a;b': {'queryA': '0.5'}, 'a;c': {'queryB': '0.7'}} - assert query_names == ['queryA', 'queryB'] - linD, query_names = combine_sumgather_csvs_by_lineage([sg1,sg2], rank="superkingdom") - print("lineage dict: \n", linD) - assert linD, query_names == {'a': {'queryA': '0.5', 'queryB': '0.7'}} - assert query_names == ['queryA', 'queryB'] - - def test_write_lineage_sample_frac(runtmp): outfrac = runtmp.output('outfrac.csv') sample_names = ['sample1', 'sample2'] @@ -875,66 +955,29 @@ def test_write_lineage_sample_frac(runtmp): def test_write_lineage_sample_frac_format_lineage(runtmp): outfrac = runtmp.output('outfrac.csv') sample_names = ['sample1', 'sample2'] - sk_lineage = lca_utils.make_lineage('a') + sk_lineage='a' print(sk_lineage) sk_linD = {sk_lineage: {'sample1': '0.500' ,'sample2': '0.700'}} with open(outfrac, 'w') as out_fp: - write_lineage_sample_frac(sample_names, sk_linD, out_fp, format_lineage=True) + write_lineage_sample_frac(sample_names, sk_linD, out_fp) frac_lines = [x.strip().split('\t') for x in open(outfrac, 'r')] print("csv_lines: ", frac_lines) assert frac_lines == [['lineage', 'sample1', 'sample2'], ['a', '0.500', '0.700']] - phy_lineage = lca_utils.make_lineage('a;b') + phy_lineage='a;b' print(phy_lineage) - phy2_lineage = lca_utils.make_lineage('a;c') + phy2_lineage = 'a;c' print(phy2_lineage) phy_linD = {phy_lineage: {'sample1': '0.500'}, phy2_lineage: {'sample2': '0.700'}} with open(outfrac, 'w') as out_fp: - write_lineage_sample_frac(sample_names, phy_linD, out_fp, format_lineage=True) + write_lineage_sample_frac(sample_names, phy_linD, out_fp) frac_lines = [x.strip().split('\t') for x in open(outfrac, 'r')] print("csv_lines: ", frac_lines) assert frac_lines == [['lineage', 'sample1', 'sample2'], ['a;b', '0.500', '0'], ['a;c', '0', '0.700']] -def test_combine_sumgather_csvs_by_lineage_improper_rank(runtmp): - # some summarized gather dicts - sum_gather1 = {'superkingdom': [SummarizedGatherResult(query_name='queryA', rank='superkingdom', fraction=0.5, - query_md5='queryA_md5', query_filename='queryA.sig', - f_weighted_at_rank=0.5, bp_match_at_rank=50, - lineage=(LineagePair(rank='superkingdom', name='a'),))], - 'phylum': [SummarizedGatherResult(query_name='queryA', rank='phylum', fraction=0.5, - query_md5='queryA_md5', query_filename='queryA.sig', - f_weighted_at_rank=0.5, bp_match_at_rank=50, - lineage=(LineagePair(rank='superkingdom', name='a'), - LineagePair(rank='phylum', name='b')))]} - sum_gather2 = {'superkingdom': [SummarizedGatherResult(query_name='queryB', rank='superkingdom', fraction=0.7, - query_md5='queryB_md5', query_filename='queryB.sig', - f_weighted_at_rank=0.7, bp_match_at_rank=70, - lineage=(LineagePair(rank='superkingdom', name='a'),))], - 'phylum': [SummarizedGatherResult(query_name='queryB', rank='phylum', fraction=0.7, - query_md5='queryB_md5', query_filename='queryB.sig', - f_weighted_at_rank=0.7, bp_match_at_rank=70, - lineage=(LineagePair(rank='superkingdom', name='a'), - LineagePair(rank='phylum', name='c')))]} - - # write summarized gather results csvs - sg1= runtmp.output("sample1.csv") - with open(sg1, 'w') as out_fp: - write_summary(sum_gather1, out_fp) - - sg2= runtmp.output("sample2.csv") - with open(sg2, 'w') as out_fp: - write_summary(sum_gather2, out_fp) - - # test combine_summarized_gather_csvs_by_lineage_at_rank - with pytest.raises(ValueError) as exc: - linD, sample_names = combine_sumgather_csvs_by_lineage([sg1,sg2], rank="strain") - print("ValueError: ", exc.value) - assert "Rank strain not available." in str(exc.value) - - def test_tax_multi_load_files(runtmp): # test loading various good and bad files taxonomy_csv = utils.get_test_data('tax/test.taxonomy.csv') @@ -990,13 +1033,13 @@ def test_tax_multi_load_files_shadowed(runtmp): assert len(db.shadowed_identifiers()) == 6 # we should have everything including strain - assert set(lca_utils.taxlist()) == set(db.available_ranks) + assert set(RankLineageInfo().taxlist) == set(db.available_ranks) db = MultiLineageDB.load([taxonomy_csv, taxonomy_db], keep_full_identifiers=False, keep_identifier_versions=False) assert len(db.shadowed_identifiers()) == 6 - assert set(lca_utils.taxlist(include_strain=False)) == set(db.available_ranks) + assert set(RankLineageInfo().taxlist[:-1]) == set(db.available_ranks) def test_tax_multi_save_files(runtmp, keep_identifiers, keep_versions): @@ -1120,3 +1163,2110 @@ def test_lineage_db_sql_load(runtmp): # file does not exist with pytest.raises(ValueError): LineageDB_Sqlite.load(runtmp.output('no-such-file')) + + +def test_LineagePair(): + lin = LineagePair(rank="rank1", name='name1') + print(lin) + assert lin.rank=="rank1" + assert lin.name =="name1" + assert lin.taxid==None + + +def test_LineagePair_1(): + lin = LineagePair(rank="rank1", name='name1', taxid=1) + assert lin.rank=="rank1" + assert lin.name =="name1" + assert lin.taxid==1 + print(lin) + + +def test_BaseLineageInfo_init_empty(): + ranks=["A", "B", "C"] + taxinf = BaseLineageInfo(ranks=ranks) + print(taxinf.lineage) + print(taxinf.lineage_str) + assert taxinf.zip_lineage()== ['', '', ''] # this is a bit odd, but it's what preserves empty ranks... + print(taxinf.filled_lineage) + assert taxinf.filled_lineage == () + assert taxinf.lowest_lineage_name == None + assert taxinf.lowest_lineage_taxid == None + assert taxinf.filled_ranks == () + assert taxinf.name_at_rank("A") == None + assert taxinf.lowest_rank == None + assert taxinf.display_lineage() == "" + assert taxinf.display_lineage(null_as_unclassified=True) == "unclassified" + + +def test_BaseLineageInfo_init_lineage_str(): + x = "a;b;c" + ranks=["A", "B", "C"] + taxinf = BaseLineageInfo(lineage_str=x, ranks=ranks) + print(taxinf.lineage) + print(taxinf.lineage_str) + assert taxinf.zip_lineage()== ['a', 'b', 'c'] + print(taxinf.filled_lineage) + assert taxinf.filled_lineage == (LineagePair(rank='A', name='a', taxid=None), + LineagePair(rank='B', name='b', taxid=None), + LineagePair(rank='C', name='c', taxid=None)) + assert taxinf.lowest_lineage_name == "c" + assert taxinf.lowest_rank == "C" + assert taxinf.name_at_rank("A") == "a" + + +def test_BaseLineageInfo_init_lineage_str_comma_sep(): + x = "a,b,c" + ranks=["A", "B", "C"] + taxinf = BaseLineageInfo(lineage_str=x, ranks=ranks) + print(taxinf.lineage) + print(taxinf.lineage_str) + assert taxinf.zip_lineage()== ['a', 'b', 'c'] + print(taxinf.filled_lineage) + assert taxinf.lowest_lineage_name == "c" + + +def test_BaseLineageInfo_init_lineage_tups(): + ranks=["A", "B", "C"] + lin_tups = (LineagePair(rank="A", name='a'), LineagePair(rank="C", name='b')) + taxinf = BaseLineageInfo(lineage=lin_tups, ranks=ranks) + print(taxinf.lineage) + print(taxinf.lineage_str) + assert taxinf.zip_lineage()== ['a', '', 'b'] + + +def test_BaseLineageInfo_init_lca_lineage_tups(): + ranks=["A", "B", "C"] + lin_tups = (LineagePair(rank="A", name='a'), LineagePair(rank="C", name='b')) + taxinf = BaseLineageInfo(lineage=lin_tups, ranks=ranks) + print(taxinf.lineage) + print(taxinf.lineage_str) + assert taxinf.zip_lineage()== ['a', '', 'b'] + + +def test_BaseLineageInfo_init_no_ranks(): + x = "a;b;c" + rankD = {"superkingdom": "a", "phylum": "b", "class": "c"} + lin_tups = (LineagePair(rank="rank2", name='name1'), LineagePair(rank="rank1", name='name1')) + with pytest.raises(TypeError) as exc: + BaseLineageInfo(lineage_str=x) + print(exc) + assert "__init__() missing 1 required positional argument: 'ranks'" in str(exc) + with pytest.raises(TypeError) as exc: + BaseLineageInfo(lineage=lin_tups) + print(exc) + assert "__init__() missing 1 required positional argument: 'ranks'" in str(exc) + + +def test_BaseLineageInfo_init_with_wrong_ranks(): + ranks=["A", "B", "C"] + lin_tups = [LineagePair(rank="rank1", name='name1')] + linD = {"rank1": "a"} + with pytest.raises(ValueError) as exc: + BaseLineageInfo(lineage=lin_tups, ranks=ranks) + print(str(exc)) + assert "Rank 'rank1' not present in A, B, C" in str(exc) + + +def test_BaseLineageInfo_init_not_lineagepair(): + ranks=["A", "B", "C"] + lin_tups = (("rank1", "name1"),) + with pytest.raises(ValueError) as exc: + BaseLineageInfo(lineage=lin_tups, ranks=ranks) + print(str(exc)) + assert "is not tax_utils LineagePair" in str(exc) + + +def test_RankLineageInfo_taxlist(): + taxinf = RankLineageInfo() + taxranks = ('superkingdom', 'phylum', 'class', 'order', 'family', 'genus', 'species', 'strain') + assert taxinf.taxlist == taxranks + assert taxinf.ascending_taxlist == taxranks[::-1] + + +def test_RankLineageInfo_init_lineage_str(): + x = "a;b;c" + taxinf = RankLineageInfo(lineage_str=x) + print(taxinf.lineage) + print(taxinf.lineage_str) + assert taxinf.zip_lineage()== ['a', 'b', 'c', '', '', '', '', ''] + + +def test_LINLineageInfo_init_empty(): + taxinf = LINLineageInfo() + assert taxinf.n_lin_positions == 0 + assert taxinf.zip_lineage()== [] + assert taxinf.display_lineage()== "" + assert taxinf.filled_ranks == () + assert taxinf.n_filled_pos == 0 + + +def test_LINLineageInfo_init_n_pos(): + n_pos = 5 + taxinf = LINLineageInfo(n_lin_positions=n_pos) + print(taxinf.lineage) + print(taxinf.lineage_str) + assert taxinf.n_lin_positions == 5 + assert taxinf.zip_lineage()== ['', '', '', '', ''] + assert taxinf.filled_ranks == () + assert taxinf.n_filled_pos == 0 + + +def test_LINLineageInfo_init_n_pos_and_lineage_str(): + x = "0;0;1" + n_pos = 5 + taxinf = LINLineageInfo(lineage_str=x, n_lin_positions=n_pos) + print(taxinf.lineage) + print(taxinf.lineage_str) + assert taxinf.n_lin_positions == 5 + assert taxinf.zip_lineage()== ['0', '0', '1', '', ''] + assert taxinf.filled_ranks == ("0","1","2") + assert taxinf.n_filled_pos == 3 + + +def test_LINLineageInfo_init_n_pos_and_lineage_str_fail(): + x = "0;0;1" + n_pos = 2 + with pytest.raises(ValueError) as exc: + LINLineageInfo(lineage_str=x, n_lin_positions=n_pos) + print(str(exc)) + assert "Provided 'n_lin_positions' has fewer positions than provided 'lineage_str'." in str(exc) + + +def test_LINLineageInfo_init_lineage_str_only(): + x = "0,0,1" + taxinf = LINLineageInfo(lineage_str=x) + print(taxinf.lineage) + print(taxinf.lineage_str) + assert taxinf.n_lin_positions == 3 + assert taxinf.zip_lineage()== ['0', '0', '1'] + assert taxinf.filled_ranks == ("0","1","2") + assert taxinf.n_filled_pos == 3 + + +def test_LINLineageInfo_init_not_lineagepair(): + lin_tups = (("rank1", "name1"),) + with pytest.raises(ValueError) as exc: + LINLineageInfo(lineage=lin_tups) + print(str(exc)) + assert "is not tax_utils LineagePair" in str(exc) + + +def test_LINLineageInfo_init_lineagepair(): + lin_tups = (LineagePair("rank1", "name1"), LineagePair("rank2", None),) + taxinf = LINLineageInfo(lineage=lin_tups) + print(taxinf.lineage) + assert taxinf.n_lin_positions == 2 + assert taxinf.zip_lineage()== ["name1", ""] + assert taxinf.zip_lineage(truncate_empty=True)== ["name1"] + assert taxinf.filled_ranks == ("rank1",) + assert taxinf.ranks == ("rank1", "rank2") + assert taxinf.n_filled_pos == 1 + + +def test_lca_LINLineageInfo_diff_n_pos(): + x = "0;0;1" + y = '0' + lin1 = LINLineageInfo(lineage_str=x) + lin2 = LINLineageInfo(lineage_str=y) + assert lin1.is_compatible(lin2) + assert lin2.is_compatible(lin1) + lca_from_lin1 = lin1.find_lca(lin2) + lca_from_lin2 = lin2.find_lca(lin1) + assert lca_from_lin1 == lca_from_lin2 + assert lca_from_lin1.display_lineage(truncate_empty=True) == "0" + + +def test_lca_LINLineageInfo_no_lca(): + x = "0;0;1" + y = '12;0;1' + lin1 = LINLineageInfo(lineage_str=x) + lin2 = LINLineageInfo(lineage_str=y) + assert lin1.is_compatible(lin2) + assert lin2.is_compatible(lin1) + lca_from_lin1 = lin1.find_lca(lin2) + lca_from_lin2 = lin2.find_lca(lin1) + assert lca_from_lin1 == lca_from_lin2 == None + + +def test_lca_RankLineageInfo_no_lca(): + x = "a;b;c" + y = 'd;e;f;g' + lin1 = RankLineageInfo(lineage_str=x) + lin2 = RankLineageInfo(lineage_str=y) + assert lin1.is_compatible(lin2) + assert lin2.is_compatible(lin1) + lca_from_lin1 = lin1.find_lca(lin2) + lca_from_lin2 = lin2.find_lca(lin1) + assert lca_from_lin1 == lca_from_lin2 == None + + +def test_incompatibility_LINLineageInfo_RankLineageInfo(): + x="a;b;c" + lin1 = RankLineageInfo(lineage_str=x) + lin2 = LINLineageInfo(lineage_str=x) + assert not lin1.is_compatible(lin2) + assert not lin2.is_compatible(lin1) + + +def test_RankLineageInfo_init_lineage_str_with_ranks_as_list(): + x = "a;b;c" + taxranks = ['superkingdom', 'phylum', 'class', 'order', 'family', 'genus', 'species'] + taxinf = RankLineageInfo(lineage_str=x, ranks=taxranks) + print(taxinf.lineage) + print(taxinf.lineage_str) + assert taxinf.zip_lineage()== ['a', 'b', 'c', '', '', '', ''] + + +def test_RankLineageInfo_init_lineage_tups(): + x = (LineagePair(rank="superkingdom", name='a'), LineagePair(rank="phylum", name='b')) + taxinf = RankLineageInfo(lineage=x) + print(taxinf.lineage) + print(taxinf.lineage_str) + assert taxinf.zip_lineage()== ['a', 'b', '', '', '', '', '', ''] + + +def test_RankLineageInfo_init_lineage_dict_fail(): + ranks=["A", "B", "C"] + lin_tups = (LineagePair(rank="A", name='a'), LineagePair(rank="C", name='b')) + with pytest.raises(ValueError) as exc: + taxinf = RankLineageInfo(ranks=ranks, lineage_dict=lin_tups) + print(str(exc)) + + assert "is not dictionary" in str(exc) + + +def test_RankLineageInfo_init_lineage_dict(): + x = {'rank1': 'name1', 'rank2': 'name2'} + taxinf = RankLineageInfo(lineage_dict=x, ranks=["rank1", "rank2"]) + print("ranks: ", taxinf.ranks) + print("lineage: ", taxinf.lineage) + print("zipped lineage: ", taxinf.zip_lineage()) + assert taxinf.zip_lineage()== ['name1', 'name2'] + + +def test_RankLineageInfo_init_lineage_dict_default_ranks(): + x = {"superkingdom":'a',"phylum":'b'} + taxinf = RankLineageInfo(lineage_dict=x) + print(taxinf.lineage) + print(taxinf.lineage_str) + assert taxinf.zip_lineage()== ['a', 'b', '', '', '', '', '', ''] + + +def test_RankLineageInfo_init_lineage_dict_withtaxpath(): + x = {'rank1': 'name1', 'rank2': 'name2', 'taxpath': "1|2"} + taxinf = RankLineageInfo(lineage_dict=x, ranks=["rank1", "rank2"]) + print("ranks: ", taxinf.ranks) + print("lineage: ", taxinf.lineage) + print("zipped lineage: ", taxinf.zip_lineage()) + print("zipped taxids: ", taxinf.zip_taxid()) + assert taxinf.zip_lineage()== ['name1', 'name2'] + assert taxinf.zip_taxid()== ['1', '2'] + assert taxinf.lowest_lineage_taxid == "2" + assert taxinf.lowest_lineage_name == "name2" + + +def test_RankLineageInfo_init_lineage_str_lineage_dict_test_eq(): + x = "a;b;c" + ranks=["A", "B", "C"] + rankD = {"A": "a", "B": "b", "C": "c"} + lin1 = RankLineageInfo(lineage_str=x, ranks=ranks) + lin2 = RankLineageInfo(lineage_dict=rankD, ranks=ranks) + assert lin1 == lin2 + + +def test_RankLineageInfo_init_lineage_dict_missing_rank(): + x = {'superkingdom': 'name1', 'class': 'name2'} + taxinf = RankLineageInfo(lineage_dict=x) + print("ranks: ", taxinf.ranks) + print("lineage: ", taxinf.lineage) + print("zipped lineage: ", taxinf.zip_lineage()) + assert taxinf.zip_lineage()== ['name1', '', 'name2', '', '', '', '', ''] + assert taxinf.zip_lineage(truncate_empty=True)== ['name1', '', 'name2'] + + +def test_RankLineageInfo_init_lineage_dict_missing_rank_with_taxpath(): + x = {'superkingdom': 'name1', 'class': 'name2', 'taxpath': '1||2'} + taxinf = RankLineageInfo(lineage_dict=x) + print("ranks: ", taxinf.ranks) + print("lineage: ", taxinf.lineage) + print("zipped lineage: ", taxinf.zip_lineage()) + assert taxinf.zip_lineage()== ['name1', '', 'name2', '', '', '', '', ''] + assert taxinf.zip_taxid()== ['1', '', '2', '', '', '', '', ''] + + +def test_RankLineageInfo_init_lineage_dict_name_taxpath_mismatch(): + # If there's no name, we don't report the taxpath, because lineage is not "filled". + # Is this desired behavior? + x = {'superkingdom': 'name1', 'taxpath': '1||2'} + taxinf = RankLineageInfo(lineage_dict=x) + print("ranks: ", taxinf.ranks) + print("lineage: ", taxinf.lineage) + print("zipped lineage: ", taxinf.zip_lineage()) + assert taxinf.zip_lineage()== ['name1', '', '', '', '', '', '', ''] + assert taxinf.zip_taxid()== ['1', '', '', '', '', '', '', ''] + + +def test_RankLineageInfo_init_lineage_dict_name_taxpath_missing_taxids(): + # If there's no name, we don't report the taxpath, because lineage is not "filled". + # Is this desired behavior? + x = {'superkingdom': 'name1', 'phylum': "name2", "class": "name3", 'taxpath': '|2'} + taxinf = RankLineageInfo(lineage_dict=x) + print("ranks: ", taxinf.ranks) + print("lineage: ", taxinf.lineage) + print("zipped lineage: ", taxinf.zip_lineage()) + print("zipped taxids: ", taxinf.zip_taxid()) + assert taxinf.zip_lineage()== ['name1', 'name2', 'name3', '', '', '', '', ''] + assert taxinf.zip_taxid()== ['', '2', '', '', '', '', '', ''] + + +def test_RankLineageInfo_init_lineage_dict_taxpath_too_long(): + x = {'superkingdom': 'name1', 'class': 'name2', 'taxpath': '1||2||||||||||'} + with pytest.raises(ValueError) as exc: + RankLineageInfo(lineage_dict=x) + print(str(exc)) + assert f"Number of NCBI taxids (13) exceeds number of ranks (8)" in str(exc) + + +def test_RankLineageInfo_init_lineage_str_lineage_dict_test_eq(): + x = "a;b;c" + rankD = {"superkingdom": "a", "phylum": "b", "class": "c"} + lin1 = RankLineageInfo(lineage_str=x) + lin2 = RankLineageInfo(lineage_dict=rankD) + print("lin1: ", lin1) + print("lin2: ", lin2) + assert lin1 == lin2 + + +def test_RankLineageInfo_init_lineage_str_1_truncate(): + x = "a;b;c" + taxinf = RankLineageInfo(lineage_str=x) + print(taxinf.lineage) + print(taxinf.lineage_str) + assert taxinf.zip_lineage(truncate_empty=True)== ['a', 'b', 'c'] + + +def test_RankLineageInfo_init_lineage_str_2(): + x = "a;b;;c" + taxinf = RankLineageInfo(lineage_str=x) + print(taxinf.lineage) + print(taxinf.lineage_str) + assert taxinf.zip_lineage()== ['a', 'b', '', 'c' '', '', '', '', ''] + + +def test_RankLineageInfo_init_lineage_str_2_truncate(): + x = "a;b;;c" + taxinf = RankLineageInfo(lineage_str=x) + print(taxinf.lineage) + print(taxinf.lineage_str) + assert taxinf.zip_lineage(truncate_empty=True)== ['a', 'b', '', 'c'] + + +def test_RankLineageInfo_init_lineage_with_incorrect_rank(): + x = [ LineagePair('superkingdom', 'a'), LineagePair("NotARank", ''), LineagePair('class', 'c') ] + with pytest.raises(ValueError) as exc: + RankLineageInfo(lineage=x) + print(str(exc)) + assert f"Rank 'NotARank' not present in " in str(exc) + + +def test_zip_lineage_1(): + x = [ LineagePair('superkingdom', 'a'), LineagePair('phylum', 'b') ] + taxinf = RankLineageInfo(lineage=x) + print("ranks: ", taxinf.ranks) + print("zipped lineage: ", taxinf.zip_lineage()) + assert taxinf.zip_lineage() == ['a', 'b', '', '', '', '', '', ''] + + +def test_zip_lineage_2(): + x = [ LineagePair('superkingdom', 'a'), LineagePair('phylum', 'b') ] + taxinf = RankLineageInfo(lineage=x) + print("ranks: ", taxinf.ranks) + print("zipped lineage: ", taxinf.zip_lineage(truncate_empty=True)) + assert taxinf.zip_lineage(truncate_empty=True) == ['a', 'b'] + + +def test_zip_lineage_3(): + x = [ LineagePair('superkingdom', 'a'), LineagePair(None, ''), LineagePair('class', 'c') ] + taxinf = RankLineageInfo(lineage=x) + assert taxinf.zip_lineage() == ['a', '', 'c', '', '', '', '', ''] + + +def test_zip_lineage_3_truncate(): + x = [ LineagePair('superkingdom', 'a'), LineagePair(None, ''), LineagePair('class', 'c') ] + taxinf = RankLineageInfo(lineage=x) + assert taxinf.zip_lineage(truncate_empty=True) == ['a', '', 'c'] + + +def test_zip_lineage_4(): + x = [ LineagePair('superkingdom', 'a'), LineagePair('class', 'c') ] + taxinf = RankLineageInfo(lineage=x) + assert taxinf.zip_lineage(truncate_empty=True) == ['a', '', 'c'] + + +def test_display_lineage_1(): + x = [ LineagePair('superkingdom', 'a'), LineagePair('phylum', 'b') ] + taxinf = RankLineageInfo(lineage=x) + assert taxinf.display_lineage() == "a;b" + + +def test_display_lineage_2(): + x = [ LineagePair('superkingdom', 'a'), LineagePair(None, ''), LineagePair('class', 'c') ] + taxinf = RankLineageInfo(lineage=x) + assert taxinf.display_lineage() == "a;;c" + + +def test_display_taxid_1(): + x = [ LineagePair('superkingdom', 'a', 1), LineagePair('phylum', 'b', 2) ] + taxinf = RankLineageInfo(lineage=x) + print(taxinf) + assert taxinf.display_taxid() == "1;2" + + +def test_display_taxid_2(): + x = [ LineagePair('superkingdom', 'name1', 1), LineagePair(None, ''), LineagePair ('class', 'name2',2) ] + taxinf = RankLineageInfo(lineage=x) + print(taxinf) + assert taxinf.display_taxid() == "1;;2" + + +def test_is_lineage_match_1(): + # basic behavior: match at order and above, but not at family or below. + lin1 = RankLineageInfo(lineage_str = 'd__a;p__b;c__c;o__d;f__e') + lin2 = RankLineageInfo(lineage_str = 'd__a;p__b;c__c;o__d;f__f') + print(lin1.lineage) + assert lin1.is_compatible(lin2) + assert lin1.is_lineage_match(lin2, 'superkingdom') + assert lin2.is_lineage_match(lin1, 'superkingdom') + assert lin1.is_lineage_match(lin2, 'phylum') + assert lin2.is_lineage_match(lin1, 'phylum') + assert lin1.is_lineage_match(lin2, 'class') + assert lin2.is_lineage_match(lin1, 'class') + assert lin1.is_lineage_match(lin2, 'order') + assert lin2.is_lineage_match(lin1, 'order') + + assert not lin1.is_lineage_match(lin2, 'family') + assert not lin2.is_lineage_match(lin1, 'family') + assert not lin1.is_lineage_match(lin2, 'genus') + assert not lin2.is_lineage_match(lin1, 'genus') + assert not lin1.is_lineage_match(lin2, 'species') + assert not lin2.is_lineage_match(lin1, 'species') + + lca_from_lin1 = lin1.find_lca(lin2) + print(lca_from_lin1.display_lineage()) + lca_from_lin2 = lin2.find_lca(lin1) + assert lca_from_lin1 == lca_from_lin2 + assert lca_from_lin1.display_lineage() == "d__a;p__b;c__c;o__d" + + + +def test_is_lineage_match_2(): + # match at family, and above, levels; no genus or species to match + lin1 = RankLineageInfo(lineage_str = 'd__a;p__b;c__c;o__d;f__f') + lin2 = RankLineageInfo(lineage_str = 'd__a;p__b;c__c;o__d;f__f') + assert lin1.is_compatible(lin2) + assert lin1.is_lineage_match(lin2, 'superkingdom') + assert lin2.is_lineage_match(lin1, 'superkingdom') + assert lin1.is_lineage_match(lin2, 'phylum') + assert lin2.is_lineage_match(lin1, 'phylum') + assert lin1.is_lineage_match(lin2, 'class') + assert lin2.is_lineage_match(lin1, 'class') + assert lin1.is_lineage_match(lin2, 'order') + assert lin2.is_lineage_match(lin1, 'order') + assert lin1.is_lineage_match(lin2, 'family') + assert lin2.is_lineage_match(lin1, 'family') + + assert not lin1.is_lineage_match(lin2, 'genus') + assert not lin2.is_lineage_match(lin1, 'genus') + assert not lin1.is_lineage_match(lin2, 'species') + assert not lin2.is_lineage_match(lin1, 'species') + + lca_from_lin1 = lin1.find_lca(lin2) + print(lca_from_lin1.display_lineage()) + lca_from_lin2 = lin2.find_lca(lin1) + assert lca_from_lin1 == lca_from_lin2 + assert lca_from_lin1.display_lineage() == "d__a;p__b;c__c;o__d;f__f" + + +def test_is_lineage_match_3(): + # one lineage is empty + lin1 = RankLineageInfo() + lin2 = RankLineageInfo(lineage_str = 'd__a;p__b;c__c;o__d;f__f') + + assert lin1.is_compatible(lin2) + assert not lin1.is_lineage_match(lin2, 'superkingdom') + assert not lin2.is_lineage_match(lin1, 'superkingdom') + assert not lin1.is_lineage_match(lin2, 'phylum') + assert not lin2.is_lineage_match(lin1, 'phylum') + assert not lin1.is_lineage_match(lin2, 'class') + assert not lin2.is_lineage_match(lin1, 'class') + assert not lin1.is_lineage_match(lin2, 'order') + assert not lin2.is_lineage_match(lin1, 'order') + assert not lin1.is_lineage_match(lin2, 'family') + assert not lin2.is_lineage_match(lin1, 'family') + assert not lin1.is_lineage_match(lin2, 'genus') + assert not lin2.is_lineage_match(lin1, 'genus') + assert not lin1.is_lineage_match(lin2, 'species') + assert not lin2.is_lineage_match(lin1, 'species') + + +def test_is_lineage_match_incorrect_ranks(): + #test comparison with incompatible ranks + taxranks = ('superkingdom', 'phylum', 'class', 'order', 'family', 'genus', 'species', 'strain') + lin1 = RankLineageInfo(lineage_str = 'd__a;p__b;c__c;o__d;f__e', ranks=taxranks[::-1]) + lin2 = RankLineageInfo(lineage_str = 'd__a;p__b;c__c;o__d;f__f') + print(lin1.lineage) + assert not lin1.is_compatible(lin2) + with pytest.raises(ValueError) as exc: + lin1.is_lineage_match(lin2, 'superkingdom') + print(str(exc)) + assert 'Cannot compare lineages from taxonomies with different ranks.' in str(exc) + + +def test_is_lineage_match_improper_rank(): + #test comparison with incompatible ranks + lin1 = RankLineageInfo(lineage_str = 'd__a;p__b;c__c;o__d;f__e') + lin2 = RankLineageInfo(lineage_str = 'd__a;p__b;c__c;o__d;f__f') + print(lin1.lineage) + assert lin1.is_compatible(lin2) + with pytest.raises(ValueError) as exc: + lin1.is_lineage_match(lin2, 'NotARank') + print(str(exc)) + assert "Desired Rank 'NotARank' not available for this lineage" in str(exc) + + +def test_pop_to_rank_1(): + # basic behavior - pop to order? + lin1 = RankLineageInfo(lineage_str='d__a;p__b;c__c;o__d') + lin2 = RankLineageInfo(lineage_str='d__a;p__b;c__c;o__d;f__f') + + print(lin1) + popped = lin2.pop_to_rank('order') + print(popped) + assert popped == lin1 + + +def test_pop_to_rank_2(): + # what if we're already above rank? + lin2 = RankLineageInfo(lineage_str='d__a;p__b;c__c;o__d;f__f') + print(lin2.pop_to_rank('species')) + assert lin2.pop_to_rank('species') == lin2 + + +def test_pop_to_rank_rank_not_avail(): + lin1 = RankLineageInfo(lineage_str = 'd__a;p__b;c__c;o__d;f__f') + with pytest.raises(ValueError) as exc: + lin1.pop_to_rank("NotARank") + print(str(exc)) + assert "Desired Rank 'NotARank' not available for this lineage" in str(exc) + + +def test_lineage_at_rank_norank(): + lin1 = RankLineageInfo(lineage_str = 'd__a;p__b;c__c;o__d;f__f') + with pytest.raises(TypeError) as exc: + lin1.lineage_at_rank() + print(str(exc)) + assert "lineage_at_rank() missing 1 required positional argument: 'rank'" in str(exc) + + +def test_lineage_at_rank_rank_not_avail(): + lin1 = RankLineageInfo(lineage_str = 'd__a;p__b;c__c;o__d;f__f') + with pytest.raises(ValueError) as exc: + lin1.lineage_at_rank("NotARank") + print(str(exc)) + assert "Desired Rank 'NotARank' not available for this lineage" in str(exc) + + +def test_lineage_at_rank_1(): + lin1 = RankLineageInfo(lineage_str = 'd__a;p__b;c__c;o__d;f__f') + print(lin1.lineage_at_rank('superkingdom')) + + assert lin1.lineage_at_rank('superkingdom') == (LineagePair(rank='superkingdom', name='d__a', taxid=None),) + print(lin1.lineage_at_rank('class')) + assert lin1.lineage_at_rank('class') == (LineagePair(rank='superkingdom', name='d__a', taxid=None), + LineagePair(rank='phylum', name='p__b', taxid=None), + LineagePair(rank='class', name='c__c', taxid=None)) + + +def test_lineage_at_rank_below_rank(): + lin1 = RankLineageInfo(lineage_str = 'd__a;p__b;c__c;o__d;f__f') + print(lin1.lineage_at_rank('superkingdom')) + # if rank is not provided, we only return the filled lineage, to follow original pop_to_rank behavior. + + print(lin1.lineage_at_rank('genus')) + assert lin1.lineage_at_rank('genus') == (LineagePair(rank='superkingdom', name='d__a', taxid=None), + LineagePair(rank='phylum', name='p__b', taxid=None), + LineagePair(rank='class', name='c__c', taxid=None), + LineagePair(rank='order', name='o__d', taxid=None), + LineagePair(rank='family', name='f__f', taxid=None)) + + +def test_TaxResult_get_match_lineage_1(): + gA_tax = ("gA", "a;b;c") + taxD = make_mini_taxonomy([gA_tax]) + + gA = {"name": "gA.1 name"} + taxres = make_TaxResult(gA) + taxres.get_match_lineage(tax_assignments=taxD) + assert taxres.lineageInfo.display_lineage() == "a;b;c" + + +def test_AnnotateTaxResult_get_match_lineage_1(): + gA_tax = ("gA", "a;b;c") + taxD = make_mini_taxonomy([gA_tax]) + + gA = {"name": "gA.1 name"} + taxres = AnnotateTaxResult(gA) + taxres.get_match_lineage(tax_assignments=taxD) + assert taxres.lineageInfo.display_lineage() == "a;b;c" + assert taxres.row_with_lineages() == {"name": "gA.1 name", "lineage": "a;b;c"} + + +def test_TaxResult_get_match_lineage_skip_ident(): + gA_tax = ("gA", "a;b;c") + taxD = make_mini_taxonomy([gA_tax]) + + gA = {"name": "gA.1 name"} + taxres = make_TaxResult(gA) + taxres.get_match_lineage(tax_assignments=taxD, skip_idents=['gA']) + print("skipped_ident?: ", taxres.skipped_ident) + print("missed_ident?: ", taxres.missed_ident) + assert taxres.skipped_ident == True + assert taxres.lineageInfo == RankLineageInfo() + assert taxres.lineageInfo.display_lineage() == "" + assert taxres.lineageInfo.display_lineage(null_as_unclassified=True) == "unclassified" + + +def test_TaxResult_get_match_lineage_missed_ident_fail_on_missing(): + gA_tax = ("gA.1", "a;b;c") + taxD = make_mini_taxonomy([gA_tax]) + + gA = {"name": "gA.1 name"} + taxres = make_TaxResult(gA) + taxres.get_match_lineage(tax_assignments=taxD, skip_idents=['gB']) + print("skipped_ident?: ", taxres.skipped_ident) + print("missed_ident?: ", taxres.missed_ident) + assert taxres.skipped_ident == False + assert taxres.missed_ident == True + assert taxres.lineageInfo == RankLineageInfo() + assert taxres.lineageInfo.display_lineage() == "" + assert taxres.lineageInfo.display_lineage(null_as_unclassified=True) == "unclassified" + + +def test_TaxResult_get_match_lineage_missed_ident_fail_on_missing(): + gA_tax = ("gA.1", "a;b;c") + taxD = make_mini_taxonomy([gA_tax]) + + gA = {"name": "gA.1 name"} + taxres = make_TaxResult(gA) + with pytest.raises(ValueError) as exc: + taxres.get_match_lineage(tax_assignments=taxD, skip_idents=['gB'], fail_on_missing_taxonomy=True) + print(str(exc)) + assert "Error: ident 'gA' is not in the taxonomy database." in str(exc) + + +def test_QueryTaxResult(): + "basic functionality: initialize and add a taxresult" + tax_info = [("gA", "a;b;c")] + taxD = make_mini_taxonomy(tax_info=tax_info) + taxres = make_TaxResult(taxD=taxD) + # initialize + q_res = QueryTaxResult(taxres.query_info) + assert q_res.ranks == [] + assert q_res.ascending_ranks == [] + q_res.add_taxresult(taxres) + # check that new querytaxres is compatible with taxres + assert q_res.is_compatible(taxres) + # check that a few thngs were set properly and/or are not yet set. + assert q_res.query_name == "q1" + assert q_res.query_info.query_bp == 100 + assert len(q_res.raw_taxresults) == 1 + assert q_res.skipped_idents == set() + assert q_res.missed_idents == set() + assert q_res.summarized_lineage_results == {} + taxranks = ('superkingdom', 'phylum', 'class', 'order', 'family', 'genus', 'species', 'strain') + assert q_res.ranks == taxranks + assert q_res.ascending_ranks == taxranks[::-1] + + +def test_QueryTaxResult_add_incompatible(): + "initialize and try to add incompatible taxresult" + tax_info = [("gA", "a;b;c")] + taxD = make_mini_taxonomy(tax_info=tax_info) + taxres = make_TaxResult(taxD=taxD) + taxres2 = make_TaxResult({'query_name': 'q2'}, taxD=taxD) + # initialize + q_res = QueryTaxResult(taxres.query_info) + # check that new querytaxres is compatible with taxres and not taxres2 + assert q_res.is_compatible(taxres) + assert not q_res.is_compatible(taxres2) + q_res.add_taxresult(taxres) + with pytest.raises(ValueError) as exc: + q_res.add_taxresult(taxres2) + print(str(exc)) + assert "Error: Cannot add TaxResult: query information does not match." in str(exc) + + +def test_QueryTaxResult_add_without_tax_info(): + "initialize and add a taxresult with missed ident" + taxres = make_TaxResult() # do not add taxonomic info + # initialize + q_res = QueryTaxResult(taxres.query_info) + print("attempted to add lineage info?: ", taxres.match_lineage_attempted) + with pytest.raises(ValueError) as exc: + q_res.add_taxresult(taxres) + print(str(exc)) + assert "Error: Cannot add TaxResult. Please use get_match_lineage() to add taxonomic lineage information first." in str(exc) + + +def test_QueryTaxResult_add_skipped_ident(): + "initialize and add a taxresult with skipped ident" + gA_tax = ("gA", "a;b;c") + taxD = make_mini_taxonomy([gA_tax]) + taxres = make_TaxResult(taxD=taxD, skip_idents = ['gA']) +# taxres.get_match_lineage(tax_assignments=taxD, skip_idents=['gA']) + # initialize + q_res = QueryTaxResult(taxres.query_info) + q_res.add_taxresult(taxres) + assert len(q_res.skipped_idents) == 1 + assert len(q_res.raw_taxresults) == 1 + assert q_res.missed_idents == set() + assert q_res.summarized_lineage_results == {} + + +def test_QueryTaxResult_add_missed_ident(): + "initialize and add a taxresult with missed ident" + gA_tax = ("gB", "a;b;c") + taxD = make_mini_taxonomy([gA_tax]) + taxres = make_TaxResult(taxD=taxD) + # initialize + q_res = QueryTaxResult(taxres.query_info) + # add taxonomic info to taxres + q_res.add_taxresult(taxres) + assert len(q_res.missed_idents) == 1 + assert len(q_res.raw_taxresults) == 1 + assert q_res.skipped_idents == set() + assert q_res.summarized_lineage_results == {} + + +def test_QueryTaxResult_track_missed_and_skipped(): + "make sure missed and skipped idents are being tracked" + # make taxonomy + tax_info = [("gA", "a;b;c"), ("gB", "a;b;d")] + taxD = make_mini_taxonomy(tax_info=tax_info) + # make results + taxres = make_TaxResult() + taxres2 = make_TaxResult({"name": 'gB'}) # skipped + taxres3 = make_TaxResult({"name": 'gB'}) # skipped + taxres4 = make_TaxResult({"name": 'gC'}) # skipped + taxres5 = make_TaxResult({"name": 'gD'}) # missed + taxres6 = make_TaxResult({"name": 'gE'}) # missed + # initialize + q_res = QueryTaxResult(taxres.query_info) + # add taxonomic info to taxres, add to q_res + for n, tr in enumerate([taxres, taxres2, taxres3, taxres4, taxres5, taxres6]): + tr.get_match_lineage(tax_assignments=taxD, skip_idents=['gB', 'gC']) + print("num: ", n) + print("skipped?: ", tr.skipped_ident) + print("missed?: ", tr.missed_ident) + q_res.add_taxresult(tr) + assert len(q_res.raw_taxresults) == 6 + print(q_res.n_skipped) + print(q_res.n_missed) + assert q_res.n_missed == 2 + assert q_res.n_skipped == 3 + assert 'gB' in q_res.skipped_idents + assert len(q_res.skipped_idents) == 2 + assert 'gD' in q_res.missed_idents + assert q_res.summarized_lineage_results == {} + + +def test_QueryTaxResult_track_missed_and_skipped_using_fn(): + "make sure missed and skipped idents are being tracked. Same as above but use helper fn." + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}, {"name": 'gB'}, {"name": 'gC'}, {"name": 'gD'}, {"name": 'gE'}] + gres = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, skip_idents=['gB', 'gC']) + # should have 6 results for default query 'q1' + print(gres.keys()) + q_res = next(iter(gres.values())) + assert len(q_res.raw_taxresults) == 6 + print(q_res.n_skipped) + print(q_res.n_missed) + assert q_res.n_missed == 2 + assert q_res.n_skipped == 3 + assert 'gB' in q_res.skipped_idents + assert len(q_res.skipped_idents) == 2 + assert 'gD' in q_res.missed_idents + assert q_res.summarized_lineage_results == {} + + +def test_QueryTaxResult_summarize_up_ranks_1(): + "basic functionality: summarize up ranks" + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + gres = make_QueryTaxResults(gather_info=gather_results, taxD=taxD) + assert len(gres.keys()) == 1 + q_res = next(iter(gres.values())) + # now summarize up the ranks + q_res.summarize_up_ranks() + assert len(q_res.raw_taxresults) == 2 + #print(q_res.sum_uniq_weighted.values()) + #print(q_res.sum_uniq_weighted['superkingdom']) + assert list(q_res.sum_uniq_weighted.keys()) == ['class', 'phylum', 'superkingdom'] + assert q_res.sum_uniq_weighted['superkingdom'] == {RankLineageInfo(lineage_str="a"): approx(0.4)} + assert q_res.sum_uniq_to_query['superkingdom'] == {RankLineageInfo(lineage_str="a"): approx(0.2)} + assert q_res.sum_uniq_bp['superkingdom'] == {RankLineageInfo(lineage_str="a"): 40} + assert q_res.sum_uniq_weighted['phylum'] == {RankLineageInfo(lineage_str="a;b"): approx(0.4)} + assert q_res.sum_uniq_to_query['phylum'] == {RankLineageInfo(lineage_str="a;b"): approx(0.2)} + assert q_res.sum_uniq_bp['phylum'] == {RankLineageInfo(lineage_str="a;b"): 40} + assert q_res.sum_uniq_weighted['class'] == {RankLineageInfo(lineage_str="a;b;c"): approx(0.2), + RankLineageInfo(lineage_str="a;b;d"): approx(0.2)} + assert q_res.sum_uniq_to_query['class'] == {RankLineageInfo(lineage_str="a;b;c"): approx(0.1), + RankLineageInfo(lineage_str="a;b;d"): approx(0.1)} + assert q_res.sum_uniq_bp['class'] == {RankLineageInfo(lineage_str="a;b;c"): 20, + RankLineageInfo(lineage_str="a;b;d"): 20} + + +def test_QueryTaxResult_summarize_up_ranks_2(): + "summarize up ranks: different values" + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB','f_unique_weighted': 0.1,'f_unique_to_query': 0.05,'unique_intersect_bp': 10,}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True) + # now summarize up the ranks + q_res.summarize_up_ranks() + assert len(q_res.raw_taxresults) == 2 + print(q_res.sum_uniq_weighted.values()) + print(q_res.sum_uniq_weighted['superkingdom']) + assert q_res.sum_uniq_weighted['superkingdom'] == {RankLineageInfo(lineage_str="a"): approx(0.3)} + assert q_res.sum_uniq_to_query['superkingdom'] == {RankLineageInfo(lineage_str="a"): approx(0.15)} + assert q_res.sum_uniq_bp['superkingdom'] == {RankLineageInfo(lineage_str="a"): 30} + assert q_res.sum_uniq_weighted['phylum'] == {RankLineageInfo(lineage_str="a;b"): approx(0.3)} + assert q_res.sum_uniq_to_query['phylum'] == {RankLineageInfo(lineage_str="a;b"): approx(0.15)} + assert q_res.sum_uniq_bp['phylum'] == {RankLineageInfo(lineage_str="a;b"): 30} + assert q_res.sum_uniq_weighted['class'] == {RankLineageInfo(lineage_str="a;b;c"): approx(0.2), + RankLineageInfo(lineage_str="a;b;d"): approx(0.1)} + assert q_res.sum_uniq_to_query['class'] == {RankLineageInfo(lineage_str="a;b;c"): approx(0.1), + RankLineageInfo(lineage_str="a;b;d"): approx(0.05)} + assert q_res.sum_uniq_bp['class'] == {RankLineageInfo(lineage_str="a;b;c"): 20, + RankLineageInfo(lineage_str="a;b;d"): 10} + + +def test_QueryTaxResult_summarize_up_ranks_missing_lineage(): + "basic functionality: summarize up ranks" + taxD = make_mini_taxonomy([("gA", "a;b;c")]) + gather_results = [{}, {"name": 'gB'}] + gres = make_QueryTaxResults(gather_info=gather_results, taxD=taxD) + assert len(gres.keys()) == 1 + q_res = next(iter(gres.values())) + # now summarize up the ranks + q_res.summarize_up_ranks() + assert len(q_res.raw_taxresults) == 2 + #print(q_res.sum_uniq_weighted.values()) + print(q_res.sum_uniq_weighted['superkingdom']) + assert q_res.sum_uniq_weighted['superkingdom'] == {RankLineageInfo(lineage_str="a"): approx(0.2)} + assert q_res.sum_uniq_to_query['superkingdom'] == {RankLineageInfo(lineage_str="a"): approx(0.1)} + assert q_res.sum_uniq_bp['superkingdom'] == {RankLineageInfo(lineage_str="a"): 20} + assert q_res.sum_uniq_weighted['phylum'] == {RankLineageInfo(lineage_str="a;b"): approx(0.2)} + assert q_res.sum_uniq_to_query['phylum'] == {RankLineageInfo(lineage_str="a;b"): approx(0.1)} + assert q_res.sum_uniq_bp['phylum'] == {RankLineageInfo(lineage_str="a;b"): 20} + assert q_res.sum_uniq_weighted['class'] == {RankLineageInfo(lineage_str="a;b;c"): approx(0.2)} + assert q_res.sum_uniq_to_query['class'] == {RankLineageInfo(lineage_str="a;b;c"): approx(0.1)} + assert q_res.sum_uniq_bp['class'] == {RankLineageInfo(lineage_str="a;b;c"): 20} + + +def test_QueryTaxResult_summarize_up_ranks_skipped_lineage(): + "basic functionality: summarize up ranks" + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + gres = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, skip_idents=['gB']) + assert len(gres.keys()) == 1 + q_res = next(iter(gres.values())) + # now summarize up the ranks + q_res.summarize_up_ranks() + assert len(q_res.raw_taxresults) == 2 + assert list(q_res.sum_uniq_weighted.keys()) == ['class', 'phylum', 'superkingdom'] + #print(q_res.sum_uniq_weighted.values()) + print(q_res.sum_uniq_weighted['superkingdom']) + assert q_res.sum_uniq_weighted['superkingdom'] == {RankLineageInfo(lineage_str="a"): approx(0.2)} + assert q_res.sum_uniq_to_query['superkingdom'] == {RankLineageInfo(lineage_str="a"): approx(0.1)} + assert q_res.sum_uniq_bp['superkingdom'] == {RankLineageInfo(lineage_str="a"): 20} + assert q_res.sum_uniq_weighted['phylum'] == {RankLineageInfo(lineage_str="a;b"): approx(0.2)} + assert q_res.sum_uniq_to_query['phylum'] == {RankLineageInfo(lineage_str="a;b"): approx(0.1)} + assert q_res.sum_uniq_bp['phylum'] == {RankLineageInfo(lineage_str="a;b"): 20} + assert q_res.sum_uniq_weighted['class'] == {RankLineageInfo(lineage_str="a;b;c"): approx(0.2)} + assert q_res.sum_uniq_to_query['class'] == {RankLineageInfo(lineage_str="a;b;c"): approx(0.1)} + assert q_res.sum_uniq_bp['class'] == {RankLineageInfo(lineage_str="a;b;c"): 20} + + +def test_QueryTaxResult_summarize_up_ranks_perfect_match(): + "summarize up ranks: different values" + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{'f_unique_to_query': 1.0}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True) + # now summarize up the ranks + q_res.summarize_up_ranks() + assert len(q_res.raw_taxresults) == 1 + print(q_res.sum_uniq_weighted.values()) + print(q_res.sum_uniq_to_query['superkingdom']) + assert list(q_res.sum_uniq_to_query['superkingdom'].values()) == [1.0] + assert 'gA' in q_res.perfect_match + + +def test_QueryTaxResult_summarize_up_ranks_already_summarized(): + "summarize up ranks: error, already summarized" + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{'f_unique_to_query': 1.0}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True) + # now summarize up the ranks + q_res.summarize_up_ranks() + with pytest.raises(ValueError) as exc: + q_res.summarize_up_ranks() + print(str(exc)) + assert "Error: already summarized" in str(exc) + + +def test_QueryTaxResult_summarize_up_ranks_already_summarized_force(): + "summarize up ranks: already summarized but force" + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB','f_unique_weighted': 0.1,'f_unique_to_query': 0.05,'unique_intersect_bp': 10,}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True) + # now summarize up the ranks + q_res.summarize_up_ranks() + q_res.summarize_up_ranks(force_resummarize=True) + assert list(q_res.sum_uniq_weighted.keys()) == ['class', 'phylum', 'superkingdom'] + + #check that all results are still good + assert len(q_res.raw_taxresults) == 2 + assert q_res.sum_uniq_weighted['superkingdom'] == {RankLineageInfo(lineage_str="a"): approx(0.3)} + assert q_res.sum_uniq_weighted['phylum'] == {RankLineageInfo(lineage_str="a;b"): approx(0.3)} + assert q_res.sum_uniq_to_query['phylum'] == {RankLineageInfo(lineage_str="a;b"): approx(0.15)} + assert q_res.sum_uniq_bp['phylum'] == {RankLineageInfo(lineage_str="a;b"): 30} + assert q_res.sum_uniq_to_query['class'] == {RankLineageInfo(lineage_str="a;b;c"): approx(0.1), + RankLineageInfo(lineage_str="a;b;d"): approx(0.05)} + assert q_res.sum_uniq_weighted['class'] == {RankLineageInfo(lineage_str="a;b;c"): approx(0.2), + RankLineageInfo(lineage_str="a;b;d"): approx(0.1)} + assert q_res.sum_uniq_bp['class'] == {RankLineageInfo(lineage_str="a;b;c"): 20, + RankLineageInfo(lineage_str="a;b;d"): 10} + + +def test_QueryTaxResult_summarize_up_ranks_single_rank(): + "summarize up ranks: different values" + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB','f_unique_weighted': 0.1,'f_unique_to_query': 0.05,'unique_intersect_bp': 10,}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True) + # now summarize up the ranks + q_res.summarize_up_ranks(single_rank='phylum') + assert len(q_res.raw_taxresults) == 2 + assert list(q_res.sum_uniq_weighted.keys()) == ['phylum'] + print(q_res.sum_uniq_weighted.keys()) + print(q_res.sum_uniq_weighted.values()) + print(q_res.sum_uniq_weighted['phylum']) + assert q_res.sum_uniq_weighted['phylum'] == {RankLineageInfo(lineage_str="a;b"): approx(0.3)} + assert list(q_res.sum_uniq_to_query['phylum'].values()) == [approx(0.15)] + assert list(q_res.sum_uniq_bp['phylum'].values()) == [30] + assert q_res.summarized_ranks == ['phylum'] + +def test_QueryTaxResult_summarize_up_ranks_single_rank_not_available(): + "summarize up ranks: different values" + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB','f_unique_weighted': 0.1,'f_unique_to_query': 0.05,'unique_intersect_bp': 10,}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True) + # now summarize up the ranks + with pytest.raises(ValueError) as exc: + q_res.summarize_up_ranks(single_rank='NotARank') + print(str(exc)) + assert "Error: rank 'NotARank' not in available ranks (strain, species, genus, family, order, class, phylum, superkingdom)" in str(exc) + + +def test_QueryTaxResult_summarize_up_ranks_single_rank_not_filled(): + "summarize up ranks: different values" + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB','f_unique_weighted': 0.1,'f_unique_to_query': 0.05,'unique_intersect_bp': 10,}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True) + # now summarize up the ranks + with pytest.raises(ValueError) as exc: + q_res.summarize_up_ranks(single_rank='species') + print(str(exc)) + assert "Error: rank 'species' was not available for any matching lineages." in str(exc) + + +def test_QueryTaxResult_build_summarized_result_1(): + "basic functionality: build summarized_result" + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True) + q_res.build_summarized_result() + print(q_res.summarized_lineage_results.keys()) + sk = [SummarizedGatherResult(rank='superkingdom', fraction=0.2, f_weighted_at_rank=0.4, + lineage=RankLineageInfo(lineage_str='a'), + bp_match_at_rank=40, query_ani_at_rank=approx(0.95, rel=1e-2)), + SummarizedGatherResult(rank='superkingdom', fraction=0.8, f_weighted_at_rank=0.6, + lineage=RankLineageInfo(), bp_match_at_rank=60, query_ani_at_rank=None)] + print(q_res.summarized_lineage_results['superkingdom']) + assert q_res.summarized_lineage_results['superkingdom'] == sk + print(q_res.summarized_lineage_results['phylum']) + phy = [SummarizedGatherResult(rank='phylum', fraction=0.2, f_weighted_at_rank=0.4, + lineage=RankLineageInfo(lineage_str='a;b'), + bp_match_at_rank=40, query_ani_at_rank=approx(0.95, rel=1e-2)), + SummarizedGatherResult(rank='phylum', fraction=0.8, f_weighted_at_rank=0.6, + lineage=RankLineageInfo(), bp_match_at_rank=60, query_ani_at_rank=None)] + assert q_res.summarized_lineage_results['phylum'] == phy + print(q_res.summarized_lineage_results['class']) + cl = [SummarizedGatherResult(rank='class', fraction=0.1, f_weighted_at_rank=0.2, + lineage=RankLineageInfo(lineage_str='a;b;c'), + bp_match_at_rank=20, query_ani_at_rank=approx(0.93, rel=1e-2)), + SummarizedGatherResult(rank='class', fraction=0.1, f_weighted_at_rank=0.2, + lineage=RankLineageInfo(lineage_str='a;b;d'), + bp_match_at_rank=20, query_ani_at_rank=approx(0.93, rel=1e-2)), + SummarizedGatherResult(rank='class', fraction=0.8, f_weighted_at_rank=0.6, + lineage=RankLineageInfo(), bp_match_at_rank=60, query_ani_at_rank=None)] + assert q_res.summarized_lineage_results['class'] == cl + + assert q_res.total_f_weighted['phylum'] == approx(0.4) + assert q_res.total_f_classified['class'] == approx(0.2) + assert q_res.total_bp_classified['superkingdom'] == 40 + + +def test_QueryTaxResult_build_summarized_result_2(): + """test two queries, build summarized result for each""" + # make mini taxonomy + gA_tax = ("gA", "a;b") + gB_tax = ("gB", "a;c") + taxD = make_mini_taxonomy([gA_tax, gB_tax]) + # make gather results + gather_results = [{'query_name': 'queryA', 'name': 'gA', 'f_unique_weighted': 0.5,'f_unique_to_query': 0.5,'unique_intersect_bp': 50}, + {'query_name': 'queryA', "name": 'gB', 'f_unique_weighted': 0.4,'f_unique_to_query': 0.3,'unique_intersect_bp': 30}, + {'query_name': 'queryB', "name": 'gB', 'f_unique_weighted': 0.3,'f_unique_to_query': 0.3,'unique_intersect_bp': 30}] + gres = make_QueryTaxResults(gather_info=gather_results, taxD=taxD) + + for query_name, q_res in gres.items(): + q_res.build_summarized_result() # summarize and build result + sk = q_res.summarized_lineage_results['superkingdom'] + phy = q_res.summarized_lineage_results['phylum'] + assert len(sk) == 2 + assert sk[0].lineage == RankLineageInfo(lineage_str="a") + print(phy) + if query_name == 'queryA': + # check superkingdom results + assert sk[0].fraction == approx(0.8) + assert sk[0].f_weighted_at_rank == approx(0.9) + assert sk[0].bp_match_at_rank == 80 + assert sk[1].fraction == approx(0.2) + assert sk[1].f_weighted_at_rank == approx(0.1) + assert sk[1].bp_match_at_rank == 20 + assert sk[1].lineage == RankLineageInfo() + # check phylum results + assert len(phy) == 3 + assert phy[0].fraction == approx(0.5) + assert phy[0].f_weighted_at_rank == approx(0.5) + assert phy[0].bp_match_at_rank == 50 + assert phy[0].lineage == RankLineageInfo(lineage_str="a;b") + assert phy[1].fraction == approx(0.3) + assert phy[1].f_weighted_at_rank == approx(0.4) + assert phy[1].bp_match_at_rank == 30 + assert phy[1].lineage == RankLineageInfo(lineage_str="a;c") + assert phy[2].fraction == approx(0.2) + assert phy[2].f_weighted_at_rank == approx(0.1) + assert phy[2].bp_match_at_rank == 20 + assert phy[2].lineage == RankLineageInfo() + if query_name == 'queryB': + # check superkingdom results + assert sk[0].fraction == approx(0.3) + assert sk[0].f_weighted_at_rank == approx(0.3) + assert sk[0].bp_match_at_rank == 30 + assert sk[1].fraction == approx(0.7) + assert sk[1].f_weighted_at_rank == approx(0.7) + assert sk[1].bp_match_at_rank == 70 + assert sk[1].lineage == RankLineageInfo() + # check phylum results + assert len(phy) == 2 + assert phy[0].fraction == approx(0.3) + assert phy[0].f_weighted_at_rank == approx(0.3) + assert phy[0].bp_match_at_rank == 30 + assert phy[0].lineage == RankLineageInfo(lineage_str="a;c") + assert phy[1].fraction == approx(0.7) + assert phy[1].f_weighted_at_rank == approx(0.7) + assert phy[1].bp_match_at_rank == 70 + assert phy[1].lineage == RankLineageInfo() + + +def test_QueryTaxResult_build_summarized_result_missing_lineage(): + "build summarized_result with missing lineage" + taxD = make_mini_taxonomy([("gA", "a;b;c")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True) + q_res.build_summarized_result() + print(q_res.summarized_lineage_results.keys()) + print(q_res.summarized_lineage_results['superkingdom']) + + sk = [SummarizedGatherResult(rank='superkingdom', fraction=0.1, f_weighted_at_rank=0.2, + lineage=RankLineageInfo(lineage_str="a"), + bp_match_at_rank=20, query_ani_at_rank=approx(0.928, rel=1e-2)), + SummarizedGatherResult(rank='superkingdom', fraction=0.9, lineage=RankLineageInfo(),f_weighted_at_rank=0.8, + bp_match_at_rank=80, query_ani_at_rank=None)] + assert q_res.summarized_lineage_results['superkingdom'] == sk + print(q_res.summarized_lineage_results['phylum']) + phy = [SummarizedGatherResult(rank='phylum', fraction=0.1, f_weighted_at_rank=0.2, + lineage=RankLineageInfo(lineage_str="a;b"), + bp_match_at_rank=20, query_ani_at_rank=approx(0.928, rel=1e-2)), + SummarizedGatherResult(rank='phylum', fraction=0.9, lineage=RankLineageInfo(),f_weighted_at_rank=0.8, + bp_match_at_rank=80, query_ani_at_rank=None)] + assert q_res.summarized_lineage_results['phylum'] == phy + print(q_res.summarized_lineage_results['class']) + cl = [SummarizedGatherResult(rank='class', fraction=0.1, lineage= RankLineageInfo(lineage_str="a;b;c"), + f_weighted_at_rank=0.2, bp_match_at_rank=20, query_ani_at_rank=approx(0.928, rel=1e-2)), + SummarizedGatherResult(rank='class', fraction=0.9, lineage=RankLineageInfo(), f_weighted_at_rank=0.8, + bp_match_at_rank=80, query_ani_at_rank=None)] + assert q_res.summarized_lineage_results['class'] == cl + + assert q_res.total_f_weighted['phylum'] == approx(0.2) + assert q_res.total_f_classified['class'] == approx(0.1) + assert q_res.total_bp_classified['superkingdom'] == 20 + + +def test_QueryTaxResult_build_summarized_result_skipped_lineage(): + "build summarized_result with skipped lineage" + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True, skip_idents=['gB']) + q_res.build_summarized_result() + print(q_res.summarized_lineage_results.keys()) + print(q_res.summarized_lineage_results['superkingdom']) + + sk = [SummarizedGatherResult(rank='superkingdom', fraction=0.1, f_weighted_at_rank=0.2, + lineage=RankLineageInfo(lineage_str="a"), + bp_match_at_rank=20, query_ani_at_rank=approx(0.928, rel=1e-2)), + SummarizedGatherResult(rank='superkingdom', fraction=0.9, lineage=RankLineageInfo(),f_weighted_at_rank=0.8, + bp_match_at_rank=80, query_ani_at_rank=None)] + assert q_res.summarized_lineage_results['superkingdom'] == sk + print(q_res.summarized_lineage_results['phylum']) + phy = [SummarizedGatherResult(rank='phylum', fraction=0.1, lineage=RankLineageInfo(lineage_str="a;b"), + f_weighted_at_rank=0.2, bp_match_at_rank=20, query_ani_at_rank=approx(0.928, rel=1e-2)), + SummarizedGatherResult(rank='phylum', fraction=0.9, lineage=RankLineageInfo(), f_weighted_at_rank=0.8, bp_match_at_rank=80, + query_ani_at_rank=None)] + assert q_res.summarized_lineage_results['phylum'] == phy + print(q_res.summarized_lineage_results['class']) + cl = [SummarizedGatherResult(rank='class', fraction=0.1,lineage=RankLineageInfo(lineage_str="a;b;c"), + f_weighted_at_rank=0.2, bp_match_at_rank=20, query_ani_at_rank=approx(0.928, rel=1e-2)), + SummarizedGatherResult(rank='class', fraction=0.9, lineage=RankLineageInfo(), f_weighted_at_rank=0.8, bp_match_at_rank=80, + query_ani_at_rank=None)] + assert q_res.summarized_lineage_results['class'] == cl + + assert q_res.total_f_weighted['phylum'] == approx(0.2) + assert q_res.total_f_classified['class'] == approx(0.1) + assert q_res.total_bp_classified['superkingdom'] == 20 + + +def test_QueryTaxResult_build_summarized_result_over100percent(): + "summarize up ranks: different values" + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB','f_unique_to_query': 0.95}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True) + # now summarize up the ranks + assert len(q_res.raw_taxresults) == 2 + with pytest.raises(ValueError) as exc: + q_res.build_summarized_result() + print(str(exc)) + assert "Summarized fraction is > 100% of the query! This should not be possible" in str(exc) + + +def test_build_summarized_result_rank_fail_not_available_resummarize(): + "build classification result" + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True) + q_res.summarize_up_ranks('superkingdom') + with pytest.raises(ValueError) as exc: + q_res.build_summarized_result(single_rank='order') + print(str(exc)) + assert "Error: rank 'order' not in summarized rank(s), superkingdom" in str(exc) + + +def test_aggregate_by_lineage_at_rank(): + """test aggregate by lineage at rank""" + # make mini taxonomy + gA_tax = ("gA", "a;b") + gB_tax = ("gB", "a;c") + taxD = make_mini_taxonomy([gA_tax, gB_tax]) + # make gather results + gather_results = [{'query_name': 'queryA', 'name': 'gA', 'f_unique_weighted': 0.5,'f_unique_to_query': 0.4,'unique_intersect_bp': 50}, + {'query_name': 'queryA', "name": 'gB', 'f_unique_weighted': 0.3,'f_unique_to_query': 0.3,'unique_intersect_bp': 30}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True, summarize=True) + summarized, all_queries = aggregate_by_lineage_at_rank([q_res], rank='phylum', by_query=False) + print(summarized) + assert summarized == {'a;b': 0.4, + 'a;c': 0.3, + 'unclassified': approx(0.3, rel=1e-2)} + assert all_queries == ['queryA'] + + +def test_aggregate_by_lineage_at_rank_not_available(): + """test aggregate by lineage at rank""" + # make mini taxonomy + gA_tax = ("gA", "a;b") + gB_tax = ("gB", "a;c") + taxD = make_mini_taxonomy([gA_tax, gB_tax]) + # make gather results + gather_results = [{'query_name': 'queryA', 'name': 'gA', 'f_unique_weighted': 0.5,'f_unique_to_query': 0.4,'unique_intersect_bp': 50}, + {'query_name': 'queryA', "name": 'gB', 'f_unique_weighted': 0.3,'f_unique_to_query': 0.3,'unique_intersect_bp': 30}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True, summarize=True) + with pytest.raises(ValueError) as exc: + aggregate_by_lineage_at_rank([q_res], rank='species', by_query=False) + print(str(exc)) + assert "Error: rank 'species' not available for aggregation." in str(exc) + + +def test_aggregate_by_lineage_at_rank_by_query(): + """test two queries, aggregate by lineage at rank by query""" + # make mini taxonomy + gA_tax = ("gA", "a;b") + gB_tax = ("gB", "a;c") + taxD = make_mini_taxonomy([gA_tax, gB_tax]) + # make gather results + gather_results = [{'query_name': 'queryA', 'name': 'gA', 'f_unique_weighted': 0.2,'f_unique_to_query': 0.2,'unique_intersect_bp': 50}, + {'query_name': 'queryA', "name": 'gB', 'f_unique_weighted': 0.3,'f_unique_to_query': 0.3,'unique_intersect_bp': 30}, + {'query_name': 'queryB', "name": 'gB', 'f_unique_weighted': 0.4,'f_unique_to_query': 0.4,'unique_intersect_bp': 30}] + gres = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, summarize=True) + # check by query + summarized, all_queries = aggregate_by_lineage_at_rank(gres.values(), rank='superkingdom', by_query=True) + print(summarized) + assert summarized == {"a": {'queryA': 0.5, 'queryB': 0.4}, + "unclassified": {'queryA': 0.5, 'queryB': 0.6}} + #assert summarized == {'a': {'queryA': approx(0.1, rel=1e-2), 'queryB': 0.7}} + assert all_queries == ['queryA', 'queryB'] + summarized, all_queries = aggregate_by_lineage_at_rank(gres.values(), rank='phylum', by_query=True) + print(summarized) + assert summarized == {'a;c': {'queryA': 0.3, 'queryB': 0.4}, + 'a;b': {'queryA': 0.2}, + "unclassified": {'queryA': 0.5, 'queryB': 0.6}} + + +def test_build_classification_result_containment_threshold_fail(): + "classification result: improper containment threshold" + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True) + with pytest.raises(ValueError) as exc: + q_res.build_classification_result(containment_threshold=1.2) + print(str(exc)) + assert "Containment threshold must be between 0 and 1 (input value: 1.2)." in str(exc) + with pytest.raises(ValueError) as exc: + q_res.build_classification_result(containment_threshold=-.1) + print(str(exc)) + assert "Containment threshold must be between 0 and 1 (input value: -0.1)." in str(exc) + + +def test_build_classification_result_containment_threshold(): + "basic functionality: build classification result using containment threshold" + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True) + + q_res.build_classification_result(containment_threshold=0.1) + print("classif: ", q_res.classification_result) + assert q_res.classification_result.status == 'match' + assert q_res.classification_result.rank == 'class' + assert q_res.classification_result.fraction == 0.1 + assert q_res.classification_result.lineage == RankLineageInfo(lineage_str="a;b;c") + assert q_res.classification_result.f_weighted_at_rank == 0.2 + assert q_res.classification_result.bp_match_at_rank == 20 + assert q_res.classification_result.query_ani_at_rank == approx(0.928, rel=1e-2) + + q_res.build_classification_result(containment_threshold=0.2) + print("classif: ", q_res.classification_result) + assert q_res.classification_result.status == 'match' + assert q_res.classification_result.rank == 'phylum' + assert q_res.classification_result.lineage == RankLineageInfo(lineage_str="a;b") + assert q_res.classification_result.f_weighted_at_rank == 0.4 + assert q_res.classification_result.fraction == 0.2 + assert q_res.classification_result.bp_match_at_rank == 40 + assert q_res.classification_result.query_ani_at_rank == approx(0.95, rel=1e-2) + + q_res.build_classification_result(containment_threshold=1.0) + print("classif: ", q_res.classification_result) + assert q_res.classification_result.status == 'below_threshold' + assert q_res.classification_result.rank == 'superkingdom' + assert q_res.classification_result.fraction == 0.2 + assert q_res.classification_result.lineage == RankLineageInfo(lineage_str="a") + assert q_res.classification_result.f_weighted_at_rank == 0.4 + assert q_res.classification_result.bp_match_at_rank == 40 + assert q_res.classification_result.query_ani_at_rank == approx(0.95, rel=1e-2) + + +def test_build_classification_result_ani_threshold(): + "basic functionality: build classification result" + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True) + + q_res.build_classification_result(ani_threshold=.92) + print("classif: ", q_res.classification_result) + assert q_res.classification_result.status == 'match' + assert q_res.classification_result.rank == 'class' + assert q_res.classification_result.fraction == 0.1 + assert q_res.classification_result.lineage == RankLineageInfo(lineage_str="a;b;c") + assert q_res.classification_result.f_weighted_at_rank == 0.2 + assert q_res.classification_result.bp_match_at_rank == 20 + assert q_res.classification_result.query_ani_at_rank == approx(0.928, rel=1e-2) + + q_res.build_classification_result(ani_threshold=0.94) # should classify at phylum + print("classif: ", q_res.classification_result) + assert q_res.classification_result.status == 'match' + assert q_res.classification_result.rank == 'phylum' + assert q_res.classification_result.fraction == 0.2 + assert q_res.classification_result.lineage == RankLineageInfo(lineage_str="a;b") + assert q_res.classification_result.f_weighted_at_rank == 0.4 + assert q_res.classification_result.bp_match_at_rank == 40 + assert q_res.classification_result.query_ani_at_rank == approx(0.95, rel=1e-2) + + # superk result, but doesn't meet ANI threshold + q_res.build_classification_result(ani_threshold=0.96) + print("classif: ", q_res.classification_result) + assert q_res.classification_result.status == 'below_threshold' + assert q_res.classification_result.rank == 'superkingdom' + assert q_res.classification_result.fraction == 0.2 + assert q_res.classification_result.lineage == RankLineageInfo(lineage_str="a") + assert q_res.classification_result.f_weighted_at_rank == 0.4 + assert q_res.classification_result.bp_match_at_rank == 40 + assert q_res.classification_result.query_ani_at_rank == approx(0.95, rel=1e-2) + + +def test_build_classification_result_ani_threshold_fail(): + "classification result: improper ANI threshold" + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True) + with pytest.raises(ValueError) as exc: + q_res.build_classification_result(ani_threshold=1.2) + print(str(exc)) + assert "ANI threshold must be between 0 and 1 (input value: 1.2)." in str(exc) + with pytest.raises(ValueError) as exc: + q_res.build_classification_result(ani_threshold=-.1) + print(str(exc)) + assert "ANI threshold must be between 0 and 1 (input value: -0.1)." in str(exc) + + +def test_build_classification_result_rank_fail_not_filled(): + "classification result: rank not available (wasn't filled in tax lineage matches)" + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True) + with pytest.raises(ValueError) as exc: + q_res.build_classification_result(rank='order') + print(str(exc)) + assert "Error: rank 'order' was not available for any matching lineages." in str(exc) + + +def test_build_classification_result_rank_fail_not_available_resummarize(): + "classification result: rank not available (wasn't summarized)" + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True) + q_res.summarize_up_ranks('superkingdom') + with pytest.raises(ValueError) as exc: + q_res.build_classification_result(rank='order') + print(str(exc)) + assert "Error: rank 'order' not in summarized rank(s), superkingdom" in str(exc) + + +def test_build_classification_result_rank_fail_not_available(): + "classification result: rank not available" + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True) + with pytest.raises(ValueError) as exc: + q_res.build_classification_result(rank='NotARank') + print(str(exc)) + assert "Error: rank 'NotARank' not in available ranks (strain, species, genus, family, order, class, phylum, superkingdom)" in str(exc) + + +def test_build_classification_result_rank_containment_threshold(): + "classification result - rank and containment threshold (default)" + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True) + + q_res.build_classification_result(rank='class') + print("classif: ", q_res.classification_result) + assert q_res.classification_result.status == 'match' + assert q_res.classification_result.rank == 'class' + assert q_res.classification_result.fraction == 0.1 + assert q_res.classification_result.lineage == RankLineageInfo(lineage_str="a;b;c") + assert q_res.classification_result.f_weighted_at_rank == 0.2 + assert q_res.classification_result.bp_match_at_rank == 20 + assert q_res.classification_result.query_ani_at_rank == approx(0.928, rel=1e-2) + + q_res.build_classification_result(rank='class', containment_threshold=0.4) + assert q_res.classification_result.status == 'below_threshold' + assert q_res.classification_result.rank == 'class' + assert q_res.classification_result.fraction == 0.1 + assert q_res.classification_result.lineage == RankLineageInfo(lineage_str="a;b;c") + assert q_res.classification_result.f_weighted_at_rank == 0.2 + assert q_res.classification_result.bp_match_at_rank == 20 + assert q_res.classification_result.query_ani_at_rank == approx(0.928, rel=1e-2) + + +def test_build_classification_result_rank_ani_threshold(): + "classification result with rank and ANI threshold" + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True) + + q_res.build_classification_result(rank='class', ani_threshold=0.92) + assert q_res.classification_result.status == 'match' + assert q_res.classification_result.rank == 'class' + assert q_res.classification_result.fraction == 0.1 + assert q_res.classification_result.lineage == RankLineageInfo(lineage_str="a;b;c") + assert q_res.classification_result.f_weighted_at_rank == 0.2 + assert q_res.classification_result.bp_match_at_rank == 20 + assert q_res.classification_result.query_ani_at_rank == approx(0.928, rel=1e-2) + + q_res.build_classification_result(rank='class', ani_threshold=0.95) + assert q_res.classification_result.status == 'below_threshold' + assert q_res.classification_result.rank == 'class' + assert q_res.classification_result.fraction == 0.1 + assert q_res.classification_result.lineage == RankLineageInfo(lineage_str="a;b;c") + assert q_res.classification_result.f_weighted_at_rank == 0.2 + assert q_res.classification_result.bp_match_at_rank == 20 + assert q_res.classification_result.query_ani_at_rank == approx(0.928, rel=1e-2) + + +def test_krona_classified(): + "basic functionality: build classification result using containment threshold" + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True) + q_res.build_classification_result() + assert q_res.krona_classified == None + q_res.build_classification_result(rank='phylum')#, force_resummarize=True) + print(q_res.krona_classified) + assert q_res.krona_classified == (0.2, 'a', 'b') + assert q_res.krona_unclassified == (0.8, 'unclassified', 'unclassified') + q_res.build_classification_result(rank='superkingdom') + print(q_res.krona_classified) + assert q_res.krona_classified == (0.2, 'a') + assert q_res.krona_unclassified == (0.8, 'unclassified') + # make sure this goes back to None if we reclassify without rank + q_res.build_classification_result() + assert q_res.krona_classified == None + assert q_res.krona_unclassified == None + assert q_res.krona_header == [] + + +def test_make_krona_header_basic(): + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + phy_header = ["fraction", "superkingdom", "phylum"] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True) + q_res.build_classification_result(rank='phylum') + print(q_res.krona_classified) + print(q_res.krona_header) + assert q_res.krona_header == phy_header + hd = q_res.make_krona_header('phylum') + print("header: ", hd) + assert hd == phy_header + + +def test_make_krona_header_basic_1(): + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + class_header = ["fraction", "superkingdom", "phylum", "class"] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True) + q_res.build_classification_result(rank='class') + assert q_res.krona_header == class_header + hd = q_res.make_krona_header(min_rank='class') + print("header: ", hd) + assert hd == class_header + + +def test_make_krona_header_fail(): + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True, summarize=True) + with pytest.raises(ValueError) as exc: + q_res.make_krona_header("order") + assert "Rank 'order' not present in summarized ranks." in str(exc.value) + with pytest.raises(ValueError) as exc: + q_res.make_krona_header("NotARank") + assert "Rank 'NotARank' not present in summarized ranks." in str(exc.value) + + +def test_make_human_summary(): + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True, summarize=True) + hs = q_res.make_human_summary(display_rank = "superkingdom") + print(hs) + assert hs == [{'rank': 'superkingdom', 'fraction': '0.800', 'lineage': 'unclassified', + 'f_weighted_at_rank': '60.0%', 'bp_match_at_rank': "60", 'query_ani_at_rank': '- ', + 'query_name': 'q1', 'query_md5': 'md5', 'query_filename': 'query_fn', + 'total_weighted_hashes': "0"}, + {'rank': 'superkingdom', 'fraction': '0.200', 'lineage': "a", + 'f_weighted_at_rank': '40.0%', 'bp_match_at_rank': "40", 'query_ani_at_rank': '94.9%', + 'query_name': 'q1', 'query_md5': 'md5', 'query_filename': 'query_fn', 'total_weighted_hashes': "0"}] + + +def test_make_human_summary_2(): + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True, summarize=True) + hs = q_res.make_human_summary(display_rank = "phylum") + print(hs) + assert hs == [{'rank': 'phylum', 'fraction': '0.800', 'lineage': 'unclassified', + 'f_weighted_at_rank': '60.0%', 'bp_match_at_rank': "60", 'query_ani_at_rank': '- ', + 'query_name': 'q1', 'query_md5': 'md5', 'query_filename': 'query_fn', + 'total_weighted_hashes': "0"}, + {'rank': 'phylum', 'fraction': '0.200', 'lineage': 'a;b', + 'f_weighted_at_rank': '40.0%', 'bp_match_at_rank': "40", 'query_ani_at_rank': '94.9%', + 'query_name': 'q1', 'query_md5': 'md5', 'query_filename': 'query_fn', 'total_weighted_hashes': "0"}] + + +def test_make_human_summary_classification(): + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True, classify=True, classify_rank="superkingdom") + hs = q_res.make_human_summary(display_rank = "superkingdom", classification=True) + print(hs) + assert hs == [{'rank': 'superkingdom', 'fraction': '0.200', 'lineage': 'a', + 'f_weighted_at_rank': '40.0%', 'bp_match_at_rank': "40", + 'query_ani_at_rank': '94.9%', 'status': 'match', 'query_name': 'q1', + 'query_md5': 'md5', 'query_filename': 'query_fn', 'total_weighted_hashes': "0"}] + + +def test_make_human_summary_classification_2(): + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True, classify=True, classify_rank="phylum") + hs = q_res.make_human_summary(display_rank = "phylum", classification=True) + print(hs) + assert hs == [{'rank': 'phylum', 'fraction': '0.200', 'lineage': 'a;b', + 'f_weighted_at_rank': '40.0%', 'bp_match_at_rank': "40", + 'query_ani_at_rank': '94.9%', 'status': 'match', + 'query_name': 'q1', 'query_md5': 'md5', + 'query_filename': 'query_fn', 'total_weighted_hashes': "0"}] + + +def test_make_full_summary(): + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True, summarize=True) + header, fs = q_res.make_full_summary() + assert header == ['query_name', 'rank', 'fraction', 'lineage', 'query_md5', 'query_filename', + 'f_weighted_at_rank', 'bp_match_at_rank', 'query_ani_at_rank', 'total_weighted_hashes'] + print(fs) + assert fs == [{'rank': 'superkingdom', 'fraction': '0.2', 'lineage': 'a', 'f_weighted_at_rank': '0.4', + 'bp_match_at_rank': '40', 'query_ani_at_rank': approx(0.949,rel=1e-3), 'query_name': 'q1', + 'query_md5': 'md5', 'query_filename': 'query_fn', 'total_weighted_hashes': '0'}, + {'rank': 'superkingdom', 'fraction': '0.8', 'lineage': 'unclassified', 'f_weighted_at_rank': + '0.6', 'bp_match_at_rank': '60', 'query_ani_at_rank': None, + 'query_name': 'q1', 'query_md5': 'md5', 'query_filename': 'query_fn', + 'total_weighted_hashes': '0'}, + {'rank': 'phylum', 'fraction': '0.2', 'lineage': 'a;b', 'f_weighted_at_rank': '0.4', + 'bp_match_at_rank': '40', 'query_ani_at_rank': approx(0.949,rel=1e-3), 'query_name': 'q1', + 'query_md5': 'md5', 'query_filename': 'query_fn', 'total_weighted_hashes': '0'}, + {'rank': 'phylum', 'fraction': '0.8', 'lineage': 'unclassified', 'f_weighted_at_rank': '0.6', + 'bp_match_at_rank': '60', 'query_ani_at_rank': None, 'query_name': 'q1', 'query_md5': 'md5', + 'query_filename': 'query_fn', 'total_weighted_hashes': '0'}, + {'rank': 'class', 'fraction': '0.1', 'lineage': 'a;b;c', 'f_weighted_at_rank': '0.2', + 'bp_match_at_rank': '20', 'query_ani_at_rank': approx(0.928, rel=1e-3), + 'query_name': 'q1', 'query_md5': 'md5', 'query_filename': 'query_fn', 'total_weighted_hashes': '0'}, + {'rank': 'class', 'fraction': '0.1', 'lineage': 'a;b;d','f_weighted_at_rank': '0.2', + 'bp_match_at_rank': '20', 'query_ani_at_rank': approx(0.928, rel=1e-3), 'query_name': 'q1', + 'query_md5': 'md5', 'query_filename': 'query_fn', 'total_weighted_hashes': '0'}, + {'rank': 'class', 'fraction': '0.8', 'lineage': 'unclassified', 'f_weighted_at_rank': '0.6', + 'bp_match_at_rank': '60', 'query_ani_at_rank': None, 'query_name': 'q1', 'query_md5': 'md5', + 'query_filename': 'query_fn', 'total_weighted_hashes': '0'}] + + header, fs = q_res.make_full_summary(limit_float=True) + assert header == ['query_name', 'rank', 'fraction', 'lineage', 'query_md5', 'query_filename', + 'f_weighted_at_rank', 'bp_match_at_rank', 'query_ani_at_rank', 'total_weighted_hashes'] + print(fs) + assert fs == [{'rank': 'superkingdom', 'fraction': '0.200', 'lineage': 'a', 'f_weighted_at_rank': '0.400', + 'bp_match_at_rank': '40', 'query_ani_at_rank': "0.949", 'query_name': 'q1', + 'query_md5': 'md5', 'query_filename': 'query_fn', 'total_weighted_hashes': '0'}, + {'rank': 'superkingdom', 'fraction': '0.800', 'lineage': 'unclassified', 'f_weighted_at_rank': + '0.600', 'bp_match_at_rank': '60', 'query_ani_at_rank': None, + 'query_name': 'q1', 'query_md5': 'md5', 'query_filename': 'query_fn', + 'total_weighted_hashes': '0'}, + {'rank': 'phylum', 'fraction': '0.200', 'lineage': 'a;b', 'f_weighted_at_rank': '0.400', + 'bp_match_at_rank': '40', 'query_ani_at_rank': "0.949", 'query_name': 'q1', + 'query_md5': 'md5', 'query_filename': 'query_fn', 'total_weighted_hashes': '0'}, + {'rank': 'phylum', 'fraction': '0.800', 'lineage': 'unclassified', 'f_weighted_at_rank': '0.600', + 'bp_match_at_rank': '60', 'query_ani_at_rank': None, 'query_name': 'q1', 'query_md5': 'md5', + 'query_filename': 'query_fn', 'total_weighted_hashes': '0'}, + {'rank': 'class', 'fraction': '0.100', 'lineage': 'a;b;c', 'f_weighted_at_rank': '0.200', + 'bp_match_at_rank': '20', 'query_ani_at_rank': "0.928", + 'query_name': 'q1', 'query_md5': 'md5', 'query_filename': 'query_fn', 'total_weighted_hashes': '0'}, + {'rank': 'class', 'fraction': '0.100', 'lineage': 'a;b;d','f_weighted_at_rank': '0.200', + 'bp_match_at_rank': '20', 'query_ani_at_rank': "0.928", 'query_name': 'q1', + 'query_md5': 'md5', 'query_filename': 'query_fn', 'total_weighted_hashes': '0'}, + {'rank': 'class', 'fraction': '0.800', 'lineage': 'unclassified', 'f_weighted_at_rank': '0.600', + 'bp_match_at_rank': '60', 'query_ani_at_rank': None, 'query_name': 'q1', 'query_md5': 'md5', + 'query_filename': 'query_fn', 'total_weighted_hashes': '0'}] + + +def test_make_full_summary_summarization_fail(): + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True, summarize=False) + with pytest.raises(ValueError) as exc: + q_res.make_full_summary() + print(str(exc)) + assert 'not summarized yet' in str(exc) + + +def test_make_full_summary_classification(): + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True, classify=True) + header, fs = q_res.make_full_summary(classification=True) + assert header == ["query_name", "status", "rank", "fraction", "lineage", + "query_md5", "query_filename", "f_weighted_at_rank", + "bp_match_at_rank", "query_ani_at_rank"] + print(fs) + assert fs == [{'rank': 'class', 'fraction': '0.1', 'lineage': 'a;b;c', 'f_weighted_at_rank': '0.2', + 'bp_match_at_rank': '20', 'query_ani_at_rank': approx(0.928, rel=1e-3), + 'status': 'match', 'query_name': 'q1', 'query_md5': 'md5', 'query_filename': 'query_fn'}] + + +def test_make_full_summary_classification_limit_float(): + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True, classify=True) + header, fs = q_res.make_full_summary(classification=True, limit_float=True) + assert header == ["query_name", "status", "rank", "fraction", "lineage", + "query_md5", "query_filename", "f_weighted_at_rank", + "bp_match_at_rank", "query_ani_at_rank"] + print(fs) + assert fs == [{'rank': 'class', 'fraction': '0.100', 'lineage': 'a;b;c', 'f_weighted_at_rank': '0.200', + 'bp_match_at_rank': '20', 'query_ani_at_rank': "0.928", + 'status': 'match', 'query_name': 'q1', 'query_md5': 'md5', 'query_filename': 'query_fn'}] + + +def test_make_full_summary_classification_fail(): + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True, summarize=True) + with pytest.raises(ValueError) as exc: + q_res.make_full_summary(classification=True) + print(str(exc)) + assert 'not classified yet' in str(exc) + + +def test_make_kreport_results(): + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;c;d;e;f;g")]) + #need to go down to species to check that `num_bp_assigned` is happening correctly + gather_results = [{"total_weighted_hashes":100}, {"name": 'gB', "total_weighted_hashes":100}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True, summarize=True) + header, krepD = q_res.make_kreport_results() + print(krepD) + assert krepD == [{'num_bp_assigned': '0', 'percent_containment': '40.00', 'num_bp_contained': '40', + 'rank_code': 'D', 'sci_name': 'a', 'ncbi_taxid': None}, + {'num_bp_assigned': '60', 'percent_containment': '60.00', 'num_bp_contained': '60', + 'sci_name': 'unclassified', 'rank_code': 'U', 'ncbi_taxid': None}, + {'num_bp_assigned': '0', 'percent_containment': '40.00', 'num_bp_contained': '40', + 'rank_code': 'P', 'sci_name': 'b', 'ncbi_taxid': None}, + {'num_bp_assigned': '0', 'percent_containment': '40.00', 'num_bp_contained': '40', + 'rank_code': 'C', 'sci_name': 'c', 'ncbi_taxid': None}, + {'num_bp_assigned': '0', 'percent_containment': '20.00', 'num_bp_contained': '20', + 'rank_code': 'O', 'sci_name': 'd', 'ncbi_taxid': None}, + {'num_bp_assigned': '0', 'percent_containment': '20.00', 'num_bp_contained': '20', + 'rank_code': 'F', 'sci_name': 'e', 'ncbi_taxid': None}, + {'num_bp_assigned': '0', 'percent_containment': '20.00', 'num_bp_contained': '20', + 'rank_code': 'G', 'sci_name': 'f', 'ncbi_taxid': None}, + {'num_bp_assigned': '20', 'percent_containment': '20.00', 'num_bp_contained': '20', + 'rank_code': 'S', 'sci_name': 'g', 'ncbi_taxid': None}] + + +def test_make_kreport_results_with_taxids(): + taxD = make_mini_taxonomy_with_taxids([("gA", "a;b;c", "1;2;3"), ("gB", "a;b;c;d;e;f;g", "1;2;3;4;5;6;7")]) + print(taxD) + #need to go down to species to check that `num_bp_assigned` is happening correctly + gather_results = [{"total_weighted_hashes":100}, {"name": 'gB', "total_weighted_hashes":100}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True, summarize=True) + header, krepD = q_res.make_kreport_results() + print(krepD) + assert krepD == [{'num_bp_assigned': '0', 'percent_containment': '40.00', 'num_bp_contained': '40', + 'rank_code': 'D', 'sci_name': 'a', 'ncbi_taxid': '1'}, + {'num_bp_assigned': '60', 'percent_containment': '60.00', 'num_bp_contained': '60', + 'sci_name': 'unclassified', 'rank_code': 'U', 'ncbi_taxid': None}, + {'num_bp_assigned': '0', 'percent_containment': '40.00', 'num_bp_contained': '40', + 'rank_code': 'P', 'sci_name': 'b', 'ncbi_taxid': '2'}, + {'num_bp_assigned': '0', 'percent_containment': '40.00', 'num_bp_contained': '40', + 'rank_code': 'C', 'sci_name': 'c', 'ncbi_taxid': '3'}, + {'num_bp_assigned': '0', 'percent_containment': '20.00', 'num_bp_contained': '20', + 'rank_code': 'O', 'sci_name': 'd', 'ncbi_taxid': '4'}, + {'num_bp_assigned': '0', 'percent_containment': '20.00', 'num_bp_contained': '20', + 'rank_code': 'F', 'sci_name': 'e', 'ncbi_taxid': '5'}, + {'num_bp_assigned': '0', 'percent_containment': '20.00', 'num_bp_contained': '20', + 'rank_code': 'G', 'sci_name': 'f', 'ncbi_taxid': '6'}, + {'num_bp_assigned': '20', 'percent_containment': '20.00', 'num_bp_contained': '20', + 'rank_code': 'S', 'sci_name': 'g', 'ncbi_taxid': '7'}] + + +def test_make_kreport_results_fail(): + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True, summarize=False) + with pytest.raises(ValueError) as exc: + q_res.make_kreport_results() + print(str(exc)) + assert 'not summarized yet' in str(exc) + + +def test_make_kreport_results_fail_pre_v450(): + taxD = make_mini_taxonomy([("gA", "a;b;c"), ("gB", "a;b;d")]) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True, summarize=True) + with pytest.raises(ValueError) as exc: + q_res.make_kreport_results() + print(str(exc)) + assert "cannot produce 'kreport' format from gather results before sourmash v4.5.0" in str(exc) + + +def test_make_cami_results_with_taxids(): + taxD = make_mini_taxonomy_with_taxids([("gA", "a;b;c", "1;2;3"), ("gB", "a;b;c;d;e;f;g", "1;2;3;4;5;6;7")]) + print(taxD) + #need to go down to species to check that `num_bp_assigned` is happening correctly + gather_results = [{"total_weighted_hashes":100}, {"name": 'gB', "total_weighted_hashes":100}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True, summarize=True) + header, camires = q_res.make_cami_bioboxes() + print(camires) + assert camires == [['1', 'superkingdom', '1', 'a', '40.00'], + ['2', 'phylum', '1|2', 'a|b', '40.00'], + ['3', 'class', '1|2|3', 'a|b|c', '40.00'], + ['4', 'order', '1|2|3|4', 'a|b|c|d', '20.00'], + ['5', 'family', '1|2|3|4|5', 'a|b|c|d|e', '20.00'], + ['6', 'genus', '1|2|3|4|5|6', 'a|b|c|d|e|f', '20.00'], + ['7', 'species', '1|2|3|4|5|6|7', 'a|b|c|d|e|f|g', '20.00']] + + +def test_make_lingroup_results(): + taxD = make_mini_taxonomy([("gA", "1;0;0"), ("gB", "1;0;1"), ("gC", "1;1;0")], LIN=True) + print(taxD) + lingroupD = {"1":"lg1", "1;0":'lg2', '1;1': "lg3"} + print(lingroupD) + gather_results = [{"total_weighted_hashes":100}, + {"name": 'gB', "total_weighted_hashes":100}, + {"name": 'gC', "total_weighted_hashes":100}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True, summarize=True, LIN=True) + print(q_res.summarized_lineage_results) + + header, lgD = q_res.make_lingroup_results(LINgroupsD = lingroupD) + print(header) + assert header == ['name', 'lin', 'percent_containment', 'num_bp_contained'] + # order may change, just check that each lg entry is present in list of results + lg1 = {'percent_containment': '60.00', 'num_bp_contained': '60', + 'lin': '1', 'name': 'lg1'} + lg2 = {'percent_containment': '40.00', 'num_bp_contained': '40', + 'lin': '1;0', 'name': 'lg2'} + lg3 = {'percent_containment': '20.00', 'num_bp_contained': '20', + 'lin': '1;1', 'name': 'lg3'} + assert lg1 in lgD + assert lg2 in lgD + assert lg3 in lgD + + +def test_make_lingroup_results_fail_pre_v450(): + taxD = make_mini_taxonomy([("gA", "1;0;0"), ("gB", "1;0;1"), ("gC", "1;1;0")], LIN=True) + gather_results = [{}, {"name": 'gB'}] + q_res = make_QueryTaxResults(gather_info=gather_results, taxD=taxD, single_query=True, summarize=True, LIN=True) + lingroupD = {"1":"lg1", "1;0":'lg2', '1;1': "lg3"} + with pytest.raises(ValueError) as exc: + q_res.make_lingroup_results(lingroupD) + print(str(exc)) + assert "cannot produce 'lingroup' format from gather results before sourmash v4.5.0" in str(exc) + + +def test_read_lingroups(runtmp): + lg_file = runtmp.output("test.lg.csv") + with open(lg_file, 'w') as out: + out.write('lin,name\n') + out.write('1,lg1\n') + out.write('1;0,lg2\n') + out.write('1;1,lg3\n') + lgD = read_lingroups(lg_file) + + assert lgD == {"1":"lg1", "1;0":'lg2', '1;1': "lg3"} + +def test_read_lingroups_empty_file(runtmp): + lg_file = runtmp.output("test.lg.csv") + with open(lg_file, 'w') as out: + out.write("") + with pytest.raises(ValueError) as exc: + read_lingroups(lg_file) + print(str(exc)) + assert f"Cannot read lingroups from '{lg_file}'. Is file empty?" in str(exc) + + +def test_read_lingroups_only_header(runtmp): + lg_file = runtmp.output("test.lg.csv") + with open(lg_file, 'w') as out: + out.write('lin,name\n') + with pytest.raises(ValueError) as exc: + read_lingroups(lg_file) + print(str(exc)) + assert f"No lingroups loaded from {lg_file}" in str(exc) + + +def test_read_lingroups_bad_header(runtmp): + lg_file = runtmp.output("test.lg.csv") + with open(lg_file, 'w') as out: + out.write('LINgroup_pfx,LINgroup_nm\n') + with pytest.raises(ValueError) as exc: + read_lingroups(lg_file) + print(str(exc)) + assert f"'{lg_file}' must contain the following columns: 'name', 'lin'." in str(exc) + + +def test_LineageTree_init(): + x = "a;b" + lin1 = RankLineageInfo(lineage_str=x) + print(lin1) + tree = LineageTree([lin1]) + assert tree.tree == { LineagePair('superkingdom', 'a'): + { LineagePair('phylum', 'b') : {}} } + +def test_LineageTree_init_mult(): + x = "a;b" + y = "a;c" + lin1 = RankLineageInfo(lineage_str=x) + lin2 = RankLineageInfo(lineage_str=y) + print(lin1) + from sourmash.tax.tax_utils import LineageTree + tree = LineageTree([lin1, lin2]) + assert tree.tree == {LineagePair(rank='superkingdom', name='a', taxid=None): + {LineagePair(rank='phylum', name='b', taxid=None): {}, + LineagePair(rank='phylum', name='c', taxid=None): {}}} + + +def test_LineageTree_init_and_add_lineage(): + x = "a;b" + y = "a;c" + lin1 = RankLineageInfo(lineage_str=x) + lin2 = RankLineageInfo(lineage_str=y) + print(lin1) + from sourmash.tax.tax_utils import LineageTree + tree = LineageTree([lin1]) + assert tree.tree == { LineagePair('superkingdom', 'a'): + { LineagePair('phylum', 'b') : {}} } + tree.add_lineage(lin2) + assert tree.tree == {LineagePair(rank='superkingdom', name='a', taxid=None): + {LineagePair(rank='phylum', name='b', taxid=None): {}, + LineagePair(rank='phylum', name='c', taxid=None): {}}} + + +def test_LineageTree_init_and_add_lineages(): + x = "a;b" + y = "a;c" + lin1 = RankLineageInfo(lineage_str=x) + lin2 = RankLineageInfo(lineage_str=y) + print(lin1) + from sourmash.tax.tax_utils import LineageTree + tree = LineageTree([lin1]) + assert tree.tree == { LineagePair('superkingdom', 'a'): + { LineagePair('phylum', 'b') : {}} } + tree.add_lineages([lin2]) + assert tree.tree == {LineagePair(rank='superkingdom', name='a', taxid=None): + {LineagePair(rank='phylum', name='b', taxid=None): {}, + LineagePair(rank='phylum', name='c', taxid=None): {}}} + + +def test_build_tree_RankLineageInfo(): + x = "a;b" + lin1 = RankLineageInfo(lineage_str=x) + print(lin1) + tree = LineageTree([lin1]) + assert tree.tree == { LineagePair('superkingdom', 'a'): + { LineagePair('phylum', 'b') : {}} } + + +def test_build_tree_LINLineageInfo(): + x = "0;3" + lin1 = LINLineageInfo(lineage_str=x) + print(lin1) + tree = LineageTree([lin1]) + assert tree.tree == { LineagePair('0', '0'): + { LineagePair('1', '3') : {}} } + + +def test_build_tree_2(): + x = "a;b" + y = "a;c" + lin1 = RankLineageInfo(lineage_str=x) + lin2 = RankLineageInfo(lineage_str=y) + print(lin1) + print(lin2) + tree = LineageTree([lin1,lin2]) + + assert tree.tree == { LineagePair('superkingdom', 'a'): { LineagePair('phylum', 'b') : {}, + LineagePair('phylum', 'c') : {}} } + + +def test_build_tree_2_LineagePairs(): + # build tree from LineagePairs + tree = LineageTree([[LineagePair('superkingdom', 'a'), LineagePair('phylum', 'b')], + [LineagePair('superkingdom', 'a'), LineagePair('phylum', 'c')], + ]) + + assert tree.tree == { LineagePair('superkingdom', 'a'): { LineagePair('phylum', 'b') : {}, + LineagePair('phylum', 'c') : {}} } + + +def test_build_tree_3(): + # empty phylum name + x='a;' + lin1 = RankLineageInfo(lineage_str=x) + tree = LineageTree([lin1]) + assert tree.tree == { LineagePair('superkingdom', 'a'): {} } + + +def test_build_tree_3_LineagePairs(): + # empty phylum name: LineagePair input + lin1 = (LineagePair('superkingdom', "a", '3'), + LineagePair('phylum', '', ''),) + tree = LineageTree([lin1]) + assert tree.tree == { LineagePair('superkingdom', 'a', '3'): {} } + + +def test_build_tree_5(): + with pytest.raises(ValueError): + tree = LineageTree([]) + + +def test_build_tree_5b(): + with pytest.raises(ValueError): + tree = LineageTree("") + + +def test_build_tree_iterable(): + with pytest.raises(ValueError) as exc: + tree = LineageTree(RankLineageInfo()) + assert "Must pass in an iterable containing LineagePair or LineageInfo objects" in str(exc) + + +def test_find_lca(): + x='a;b' + lin1 = RankLineageInfo(lineage_str=x) + tree = LineageTree([lin1]) + lca = tree.find_lca() + + assert lca == ((LineagePair('superkingdom', 'a'), LineagePair('phylum', 'b'),), 0) + + +def test_find_lca_LineagePairs(): + tree = LineageTree([[LineagePair('rank1', 'name1'), LineagePair('rank2', 'name2')]]) + lca = tree.find_lca() + + assert lca == ((LineagePair('rank1', 'name1'), LineagePair('rank2', 'name2'),), 0) + + +def test_find_lca_2(): + x = "a;b" + y = "a;c" + lin1 = RankLineageInfo(lineage_str=x) + lin2 = RankLineageInfo(lineage_str=y) + + tree = LineageTree([lin1, lin2]) + lca = tree.find_lca() + + assert lca == ((LineagePair('superkingdom', 'a'),), 2) + + +def test_find_lca_LIN(): + x = "5;6" + y = "5;10" + lin1 = LINLineageInfo(lineage_str=x) + lin2 = LINLineageInfo(lineage_str=y) + + tree = LineageTree([lin1, lin2]) + lca = tree.find_lca() + + assert lca == ((LineagePair('0', '5'),), 2) + print(lca) + + +def test_find_lca_2_LineagePairs(): + tree = LineageTree([[LineagePair('rank1', 'name1'), LineagePair('rank2', 'name2a')], + [LineagePair('rank1', 'name1'), LineagePair('rank2', 'name2b')], + ]) + lca = tree.find_lca() + + assert lca == ((LineagePair('rank1', 'name1'),), 2) + + +def test_find_lca_3(): + lin1 = RankLineageInfo(lineage_str="a;b;c") + lin2 = RankLineageInfo(lineage_str="a;b") + + tree = LineageTree([lin1, lin2]) + lca, reason = tree.find_lca() + assert lca == lin1.filled_lineage # find most specific leaf node + print(lca) + + +def test_build_tree_with_initial(): + x = "a;b;c" + y = "a;b;d" + z = "a;e" + lin1 = RankLineageInfo(lineage_str=x) + lin2 = RankLineageInfo(lineage_str=y) + lin3 = RankLineageInfo(lineage_str=z) + + tree = LineageTree([lin1, lin2]) + lca = tree.find_lca() + + print(lca) + assert lca == ((LineagePair(rank='superkingdom', name='a', taxid=None), + LineagePair(rank='phylum', name='b', taxid=None)), 2) + tree.add_lineages([lin3]) + lca2 = tree.find_lca() + print(lca2) + assert lca2 == ((LineagePair('superkingdom', 'a'),), 2) + + +def test_LineageTree_find_ordered_paths(): + x = "a;b;c" + y = "a;b;d" + z = "a;e" + lin1 = RankLineageInfo(lineage_str=x) + lin2 = RankLineageInfo(lineage_str=y) + lin3 = RankLineageInfo(lineage_str=z) + + tree = LineageTree([lin1, lin2, lin3]) + paths = tree.ordered_paths() + + print(paths) + assert paths == [(LineagePair(rank='superkingdom', name='a', taxid=None), + LineagePair(rank='phylum', name='e', taxid=None)), + (LineagePair(rank='superkingdom', name='a', taxid=None), + LineagePair(rank='phylum', name='b', taxid=None), + LineagePair(rank='class', name='c', taxid=None)), + (LineagePair(rank='superkingdom', name='a', taxid=None), + LineagePair(rank='phylum', name='b', taxid=None), + LineagePair(rank='class', name='d', taxid=None))] + + +def test_LineageTree_find_ordered_paths_include_internal(): + x = "a;b;c" + y = "a;b;d" + z = "a;e" + lin1 = RankLineageInfo(lineage_str=x) + lin2 = RankLineageInfo(lineage_str=y) + lin3 = RankLineageInfo(lineage_str=z) + + tree = LineageTree([lin1, lin2, lin3]) + paths = tree.ordered_paths(include_internal=True) + + print(paths) + + assert paths == [(LineagePair(rank='superkingdom', name='a', taxid=None),), + (LineagePair(rank='superkingdom', name='a', taxid=None), + LineagePair(rank='phylum', name='e', taxid=None)), + (LineagePair(rank='superkingdom', name='a', taxid=None), + LineagePair(rank='phylum', name='b', taxid=None)), + (LineagePair(rank='superkingdom', name='a', taxid=None), + LineagePair(rank='phylum', name='b', taxid=None), + LineagePair(rank='class', name='c', taxid=None)), + (LineagePair(rank='superkingdom', name='a', taxid=None), + LineagePair(rank='phylum', name='b', taxid=None), + LineagePair(rank='class', name='d', taxid=None))] diff --git a/tox.ini b/tox.ini index ee5b7e31d0..589d07b8cb 100644 --- a/tox.ini +++ b/tox.ini @@ -1,9 +1,9 @@ [tox] envlist = py310, + py311, py39, coverage, - codecov, docs, package_description py38, @@ -16,7 +16,8 @@ isolated_build = true skip_missing_interpreters = true [testenv] -setuptools_version = setuptools<60 +package = wheel +wheel_build_env = .pkg description = run the tests with pytest under {basepython} setenv = PIP_DISABLE_VERSION_CHECK = 1 @@ -78,7 +79,7 @@ commands = pytest \ [testenv:khmer_master] basepython = python3.8 deps = - -e git+https://github.com/dib-lab/khmer.git#egg=khmer + -e git+https://github.com/dib-lab/khmer.git\#egg=khmer commands = pytest \ --cov "{envsitepackagesdir}/sourmash" \ --cov-config "{toxinidir}/tox.ini" \ @@ -99,7 +100,7 @@ commands = [testenv:docs] description = invoke sphinx-build to build the HTML docs -basepython = python3.8 +basepython = python3.10 extras = doc whitelist_externals = pandoc passenv = HOME @@ -161,22 +162,6 @@ commands = coverage combine depends = py39, py38, py37, pypy3 parallel_show_output = True -[testenv:codecov] -description = [only run on CI]: upload coverage data to codecov (depends on coverage running first) -passenv = {[testenv]passenv} - CODECOV_* - GITHUB_ACTION - GITHUB_REF - GITHUB_HEAD_REF - GITHUB_RUN_ID - GITHUB_SHA - GITHUB_REPOSITORY -deps = codecov -skip_install = True -changedir = {toxinidir} -depends = coverage -commands = codecov -e $TOXENV --file "{toxworkdir}/coverage.xml" -F python {posargs} - [testenv:X] description = print the positional arguments passed in with echo commands = echo {posargs} @@ -209,9 +194,10 @@ source = src/sourmash/ [gh-actions] python = - 3.10: py310, docs, package_description, coverage, codecov - 3.9: py39, coverage, codecov - 3.8: py38, coverage, codecov + 3.10: py310, docs, package_description, coverage + 3.11: py311, coverage + 3.9: py39, coverage + 3.8: py38, coverage [flake8] max-complexity = 22 From 2c6b194ab9f676d8df7a6bd543ebb1c2f3f35789 Mon Sep 17 00:00:00 2001 From: "C. Titus Brown" Date: Mon, 21 Aug 2023 08:23:09 -0700 Subject: [PATCH 08/22] add some docs --- doc/command-line.md | 41 ++++++++++++++++++++++++++++++++++++++++ src/sourmash/commands.py | 1 + 2 files changed, 42 insertions(+) diff --git a/doc/command-line.md b/doc/command-line.md index 12f6a9005c..a05b14a558 100644 --- a/doc/command-line.md +++ b/doc/command-line.md @@ -326,6 +326,10 @@ metagenome and genome bin analysis. (See [Classifying Signatures](classifying-signatures.md) for more information on the different approaches that can be used here.) +`sourmash gather` takes exactly one query and one or more +[collections of signatures](#storing-and-searching-signatures). Please see +`sourmash multigather` (@CTB link) if you have multiple queries! + If the input signature was created with `-p abund`, output will be abundance weighted (unless `--ignore-abundances` is specified). `-o/--output` will create a CSV file containing the @@ -482,6 +486,43 @@ This combination of commands ensures that the more time- and memory-intensive `gather` step is run only on a small set of relevant signatures, rather than all the signatures in the database. +### `sourmash multigather` - do gather with many queries + +The `multigather` subcommand runs `sourmash gather` on multiple +queries. (See +[`sourmash gather` docs](#sourmash-gather-find-metagenome-members) for +specifics on what gather does, and how!) + +Usage: +``` +sourmash multigather --query --db +``` + +Note that multigather is single threaded, so it offers no substantial +efficiency gains over just running gather multiple times! Nontheless, it +is useful for situations where you have many sketches organized in a +combined file, e.g. sketches built with `sourmash sketch +... --singleton`). + +#### `multigather` output files + +multigather produces three output files for each +query: + +* `.csv` - gather CSV output +* `.matches.sig` - all matching outputs +* `.unassigned.sig` - all remaining unassigned hashes +``` + +As of sourmash v4.8.4, `` is set to: +* the query filename, if it is not empty or `-`; +* the query sketch md5sum, if the query filename is empty or `-`; +* the query filename + the query sketch md5sum + (`.`), if `-U/--output-add-query-md5sum` is + specified; + +CTB more here! + ## `sourmash tax` subcommands for integrating taxonomic information into gather results The sourmash `tax` or `taxonomy` commands integrate taxonomic diff --git a/src/sourmash/commands.py b/src/sourmash/commands.py index b48601af83..fcbe3ba575 100644 --- a/src/sourmash/commands.py +++ b/src/sourmash/commands.py @@ -1083,6 +1083,7 @@ def multigather(args): output_base = query.md5sum() elif args.output_add_query_md5sum: # Uniquify the output file if all signatures were made from the same file (e.g. with --singleton) + # @CTB check if query_filename is empty. output_base = os.path.basename(query_filename) + "." + query.md5sum() else: output_base = os.path.basename(query_filename) From d6dd054403a52ac325592b37cc10f3b2d9562f99 Mon Sep 17 00:00:00 2001 From: "C. Titus Brown" Date: Tue, 22 Aug 2023 07:11:55 -0700 Subject: [PATCH 09/22] add new option + tests + docs --- doc/command-line.md | 11 ++++++++--- src/sourmash/cli/multigather.py | 4 ++++ src/sourmash/commands.py | 8 ++++++-- tests/test_sourmash.py | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/doc/command-line.md b/doc/command-line.md index a05b14a558..5c3a3ac9b5 100644 --- a/doc/command-line.md +++ b/doc/command-line.md @@ -514,14 +514,19 @@ query: * `.unassigned.sig` - all remaining unassigned hashes ``` -As of sourmash v4.8.4, `` is set to: -* the query filename, if it is not empty or `-`; +As of sourmash v4.8.4, `` is set as follows: +* the query filename, if it is not empty or `-`; (@CTB is this --query filename or sketch filename?) * the query sketch md5sum, if the query filename is empty or `-`; * the query filename + the query sketch md5sum (`.`), if `-U/--output-add-query-md5sum` is specified; -CTB more here! +By default, `multigather` will complain and exit with an error if +the same `` is used repeatedly and an output file is +going to be overwritten. With `-U/--output-add-query-md5sum` this +should only happen when identical sketches are present in a query +database. Please use `--force-allow-overwrite-output` +to allow overwriting of output files in this case. ## `sourmash tax` subcommands for integrating taxonomic information into gather results diff --git a/src/sourmash/cli/multigather.py b/src/sourmash/cli/multigather.py index 2592959615..aca3cea648 100644 --- a/src/sourmash/cli/multigather.py +++ b/src/sourmash/cli/multigather.py @@ -76,6 +76,10 @@ def subparser(subparsers): '--fail-on-empty-database', action='store_true', help='stop at databases that contain no compatible signatures' ) + subparser.add_argument( + '--force-allow-overwrite-output', action='store_true', + help='allow output files to be overwritten' + ) subparser.add_argument( '--no-fail-on-empty-database', action='store_false', dest='fail_on_empty_database', diff --git a/src/sourmash/commands.py b/src/sourmash/commands.py index fcbe3ba575..803860eb55 100644 --- a/src/sourmash/commands.py +++ b/src/sourmash/commands.py @@ -1094,8 +1094,12 @@ def multigather(args): # track overwrites if output_base in output_base_tracking: error(f"ERROR: detected overwritten outputs! '{output_base}' has already been used. Failing.") - error("Consider using '-U/----output-add-query-md5sum'.") - sys.exit(-1) + if args.force_allow_overwrite_output: + error(f"continuing because --force-allow-overwrite was specified") + else: + error("Consider using '-U/----output-add-query-md5sum' to build unique outputs") + error("and/or '--force-allow-overwrite-output'") + sys.exit(-1) output_base_tracking.add(output_base) diff --git a/tests/test_sourmash.py b/tests/test_sourmash.py index dfc0586dd4..2d205a3f1c 100644 --- a/tests/test_sourmash.py +++ b/tests/test_sourmash.py @@ -4080,6 +4080,38 @@ def test_multigather_metagenome_query_with_sbt_addl_query_fail_overwrite(runtmp) assert "ERROR: detected overwritten outputs! 'GCF_000195995.1_ASM19599v1_genomic.fna.gz' has already been used. Failing." in err +def test_multigather_metagenome_query_with_sbt_addl_query_fail_overwrite_force(runtmp): + # provide multiple identical queries - fails -> overwrite with --force + c = runtmp + + testdata_glob = utils.get_test_data('gather/GCF*.sig') + testdata_sigs = glob.glob(testdata_glob) + another_query = utils.get_test_data('gather/GCF_000195995.1_ASM19599v1_genomic.fna.gz.sig') + + query_sig = utils.get_test_data('gather/combined.sig') + + cmd = ['index', 'gcf_all.sbt.zip'] + cmd.extend(testdata_sigs) + cmd.extend(['-k', '21']) + c.run_sourmash(*cmd) + + assert os.path.exists(c.output('gcf_all.sbt.zip')) + + cmd = 'multigather --query {} {} --db gcf_all.sbt.zip -k 21 --threshold-bp=0 --force-allow-overwrite-output'.format(another_query, another_query) + cmd = cmd.split(' ') + + + c.run_sourmash(*cmd) + + out = c.last_result.out + print(out) + err = c.last_result.err + print(err) + + assert "ERROR: detected overwritten outputs! 'GCF_000195995.1_ASM19599v1_genomic.fna.gz' has already been used. Failing." in err + assert "continuing because --force-allow-overwrite was specified" in err + + def test_multigather_metagenome_sbt_query_from_file_with_addl_query(runtmp): c = runtmp From 0b7b7f600933e5ff0199b61d84b779cc56a7d442 Mon Sep 17 00:00:00 2001 From: "C. Titus Brown" Date: Wed, 23 Aug 2023 06:30:04 -0700 Subject: [PATCH 10/22] cleanup on aisle 10 --- src/sourmash/cli/multigather.py | 4 +++ src/sourmash/commands.py | 46 +++++++++++++++++++-------------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/src/sourmash/cli/multigather.py b/src/sourmash/cli/multigather.py index aca3cea648..f7962b66e2 100644 --- a/src/sourmash/cli/multigather.py +++ b/src/sourmash/cli/multigather.py @@ -95,6 +95,10 @@ def subparser(subparsers): '-U', '--output-add-query-md5sum', action='store_true', help='add md5sum of each query to ensure unique output file names' ) + subparser.add_argument( + '-E', '--extension', type=str, default='.sig', + help="write signature files with this extension ('.sig' by default)" + ) add_ksize_arg(subparser) add_moltype_args(subparser) diff --git a/src/sourmash/commands.py b/src/sourmash/commands.py index 803860eb55..b993e96ab6 100644 --- a/src/sourmash/commands.py +++ b/src/sourmash/commands.py @@ -1091,7 +1091,7 @@ def multigather(args): if args.output_dir: output_base = os.path.join(args.output_dir, output_base) - # track overwrites + # track overwrites of output files! if output_base in output_base_tracking: error(f"ERROR: detected overwritten outputs! '{output_base}' has already been used. Failing.") if args.force_allow_overwrite_output: @@ -1103,8 +1103,8 @@ def multigather(args): output_base_tracking.add(output_base) + # write out basic CSV file output_csv = output_base + '.csv' - notify(f'saving all CSV matches to "{output_csv}"') w = None with FileOutputCSV(output_csv) as fp: @@ -1113,36 +1113,42 @@ def multigather(args): w = result.init_dictwriter(fp) result.write(w) - output_matches = output_base + '.matches.sig' + ### save matching sketches! + output_matches = output_base + f'.matches{args.extension}' with SaveSignaturesToLocation(output_matches) as save_sig: notify(f"saving all matching signatures to '{output_matches}'") save_sig.add_many([ r.match for r in found ]) - output_unassigned = output_base + '.unassigned.sig' - with open(output_unassigned, 'wt') as fp: - remaining_query = gather_iter.query - if noident_mh: - remaining_mh = remaining_query.minhash.to_mutable() - remaining_mh += noident_mh.downsample(scaled=remaining_mh.scaled) - remaining_query.minhash = remaining_mh + ### save unassigned hashes! + output_unassigned = output_base + f'.unassigned{args.extension}' + remaining_query = gather_iter.query + if noident_mh: # add hashes with no match in database + remaining_mh = remaining_query.minhash.to_mutable() + remaining_mh += noident_mh.downsample(scaled=remaining_mh.scaled) + remaining_query.minhash = remaining_mh - if is_abundance: - abund_query_mh = remaining_query.minhash.inflate(orig_query_mh) - remaining_query.minhash = abund_query_mh + if is_abundance: + abund_query_mh = remaining_query.minhash.inflate(orig_query_mh) + remaining_query.minhash = abund_query_mh - if not found: - notify('nothing found - entire query signature unassigned.') - elif not remaining_query: - notify('no unassigned hashes! not saving.') - else: - notify(f'saving unassigned hashes to "{output_unassigned}"') + # only save if we found matches and there are things to save! + if found and remaining_query: + notify(f'saving unassigned hashes to "{output_unassigned}"') with SaveSignaturesToLocation(output_unassigned) as save_sig: - # CTB: note, multigather does not save abundances save_sig.add(remaining_query) + elif not found: + notify('nothing found - entire query signature unassigned.') + elif not remaining_query: + notify('no unassigned hashes! not saving.') + else: + assert 0, "should be unreachable" + n += 1 # fini, next query! + + # done! report at end. notify(f'\nconducted gather searches on {n} signatures') if size_may_be_inaccurate: notify("WARNING: size estimation for at least one of these sketches may be inaccurate. ANI values will not be reported for these comparisons.") From 53e72413fe3e989a025deddcd8a75dfe20361c1a Mon Sep 17 00:00:00 2001 From: "C. Titus Brown" Date: Wed, 23 Aug 2023 06:33:57 -0700 Subject: [PATCH 11/22] more cleanup of tests --- tests/test_sourmash.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/test_sourmash.py b/tests/test_sourmash.py index 2d205a3f1c..70e860ba7d 100644 --- a/tests/test_sourmash.py +++ b/tests/test_sourmash.py @@ -3950,8 +3950,9 @@ def test_multigather_metagenome_query_with_sbt(c): 'NC_000853.1 Thermotoga maritima MSB8 ' in out)) -@utils.in_tempdir -def test_multigather_metagenome_query_with_lca(c): +def test_multigather_metagenome_query_with_lca(runtmp): + # make sure that LCA databases can be used as queries + c = runtmp testdata_glob = utils.get_test_data('47*.fa.sig') testdata_sigs = glob.glob(testdata_glob) @@ -3980,8 +3981,9 @@ def test_multigather_metagenome_query_with_lca(c): assert '5.5 Mbp 100.0% 69.4% 491c0a81' in out -@utils.in_tempdir -def test_multigather_metagenome_query_on_lca_db(c): +def test_multigather_metagenome_query_on_lca_db(runtmp): + # test multigather against LCA databases + c = runtmp testdata_sig1 = utils.get_test_data('47.fa.sig') testdata_sig2 = utils.get_test_data('63.fa.sig') @@ -4161,8 +4163,9 @@ def test_multigather_metagenome_sbt_query_from_file_with_addl_query(runtmp): 'NC_003198.1 Salmonella enterica subsp' in out)) -@utils.in_tempdir -def test_multigather_metagenome_sbt_query_from_file_incorrect(c): +def test_multigather_metagenome_sbt_query_from_file_incorrect(runtmp): + # use the wrong type of file with --query-from-file + c = runtmp testdata_glob = utils.get_test_data('gather/GCF*.sig') testdata_sigs = glob.glob(testdata_glob) @@ -4187,8 +4190,10 @@ def test_multigather_metagenome_sbt_query_from_file_incorrect(c): print(c.last_result.err) -@utils.in_tempdir -def test_multigather_metagenome_lca_query_from_file(c): +def test_multigather_metagenome_lca_query_from_file(runtmp): + # putting an LCA database in a file for a query should work + c = runtmp + testdata_glob = utils.get_test_data('47*.fa.sig') testdata_sigs = glob.glob(testdata_glob) @@ -4221,9 +4226,10 @@ def test_multigather_metagenome_lca_query_from_file(c): assert '5.5 Mbp 100.0% 69.4% 491c0a81' in out -@utils.in_tempdir def test_multigather_metagenome_query_from_file_with_addl_query(c): # test multigather --query-from-file and --query too + c = runtmp + testdata_glob = utils.get_test_data('gather/GCF*.sig') testdata_sigs = glob.glob(testdata_glob) From 3d0467eb2acb5e3f6d0247ab3a80dc2f12fd65f0 Mon Sep 17 00:00:00 2001 From: "C. Titus Brown" Date: Wed, 23 Aug 2023 06:41:54 -0700 Subject: [PATCH 12/22] test -E/--extension --- tests/conftest.py | 5 +++++ tests/test_sourmash.py | 16 +++++++++------- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 3281133cd5..2c4b1e6fa6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -80,6 +80,11 @@ def sig_save_extension(request): return request.param +@pytest.fixture(params=['sig', 'sig.gz', 'zip', '.d/']) +def sig_save_extension_abund(request): + return request.param + + # --- BEGIN - Only run tests using a particular fixture --- # # Cribbed from: http://pythontesting.net/framework/pytest/pytest-run-tests-using-particular-fixture/ def pytest_collection_modifyitems(items, config): diff --git a/tests/test_sourmash.py b/tests/test_sourmash.py index 70e860ba7d..4f1129de54 100644 --- a/tests/test_sourmash.py +++ b/tests/test_sourmash.py @@ -3914,8 +3914,9 @@ def test_multigather_metagenome_output_outdir(runtmp): assert len(x) == 13 -@utils.in_tempdir -def test_multigather_metagenome_query_with_sbt(c): +def test_multigather_metagenome_query_with_sbt(runtmp): + # multigather should work with an SBT as a query + c = runtmp testdata_glob = utils.get_test_data('gather/GCF*.sig') testdata_sigs = glob.glob(testdata_glob) @@ -4226,7 +4227,7 @@ def test_multigather_metagenome_lca_query_from_file(runtmp): assert '5.5 Mbp 100.0% 69.4% 491c0a81' in out -def test_multigather_metagenome_query_from_file_with_addl_query(c): +def test_multigather_metagenome_query_from_file_with_addl_query(runtmp): # test multigather --query-from-file and --query too c = runtmp @@ -5132,12 +5133,12 @@ def test_gather_empty_db_nofail(runtmp, prefetch_gather, linear_gather): assert "loaded 50 total signatures from 2 locations" in err assert "after selecting signatures compatible with search, 0 remain." in err -def test_multigather_output_unassigned_with_abundance(runtmp): +def test_multigather_output_unassigned_with_abundance(runtmp, sig_save_extension_abund): c = runtmp query = utils.get_test_data('gather-abund/reads-s10x10-s11.sig') against = utils.get_test_data('gather-abund/genome-s10.fa.gz.sig') - cmd = 'multigather --query {} --db {}'.format(query, against).split() + cmd = 'multigather --query {} --db {} -E {}'.format(query, against, sig_save_extension_abund).split() c.run_sourmash(*cmd) print(c.last_result.out) @@ -5147,9 +5148,10 @@ def test_multigather_output_unassigned_with_abundance(runtmp): assert "the recovered matches hit 91.0% of the abundance-weighted query." in out assert "the recovered matches hit 57.2% of the query k-mers (unweighted)." in out - assert os.path.exists(c.output('r3.fa.unassigned.sig')) + assert os.path.exists(c.output(f'r3.fa.unassigned{sig_save_extension_abund}')) - nomatch = sourmash.load_one_signature(c.output('r3.fa.unassigned.sig')) + nomatch = sourmash.load_file_as_signatures(c.output(f'r3.fa.unassigned{sig_save_extension_abund}')) + nomatch = list(nomatch)[0] assert nomatch.minhash.track_abundance query_ss = sourmash.load_one_signature(query) From eba4d2acc3afc4630865109eec494fc3496103ff Mon Sep 17 00:00:00 2001 From: "C. Titus Brown" Date: Thu, 19 Oct 2023 08:01:27 -0400 Subject: [PATCH 13/22] typo --- src/sourmash/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sourmash/commands.py b/src/sourmash/commands.py index 73ac7dc962..3aa25e4417 100644 --- a/src/sourmash/commands.py +++ b/src/sourmash/commands.py @@ -1113,7 +1113,7 @@ def multigather(args): if args.force_allow_overwrite_output: error(f"continuing because --force-allow-overwrite was specified") else: - error("Consider using '-U/----output-add-query-md5sum' to build unique outputs") + error("Consider using '-U/--output-add-query-md5sum' to build unique outputs") error("and/or '--force-allow-overwrite-output'") sys.exit(-1) From 7296cb7ea2af435ba72fd52883a38705575e8838 Mon Sep 17 00:00:00 2001 From: "C. Titus Brown" Date: Wed, 28 Feb 2024 06:13:31 -0800 Subject: [PATCH 14/22] fix multigather argparse --- src/sourmash/cli/multigather.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/sourmash/cli/multigather.py b/src/sourmash/cli/multigather.py index 5dc186645d..041ec03421 100644 --- a/src/sourmash/cli/multigather.py +++ b/src/sourmash/cli/multigather.py @@ -79,14 +79,6 @@ def subparser(subparsers): action="store_true", help="stop at databases that contain no compatible signatures", ) - subparser.add_argument( - '--estimate-ani-ci', action='store_true', - help='also output confidence intervals for ANI estimates' - ) - subparser.add_argument( - '--fail-on-empty-database', action='store_true', - help='stop at databases that contain no compatible signatures' - ) subparser.add_argument( '--force-allow-overwrite-output', action='store_true', help='allow output files to be overwritten' From e8bb12f2638dacdbd8d178c58f4810fba72189c2 Mon Sep 17 00:00:00 2001 From: "C. Titus Brown" Date: Wed, 28 Feb 2024 06:32:32 -0800 Subject: [PATCH 15/22] cleanup from merge --- src/sourmash/commands.py | 56 +++++++++++++--------------------------- 1 file changed, 18 insertions(+), 38 deletions(-) diff --git a/src/sourmash/commands.py b/src/sourmash/commands.py index 22cf3cfeaf..d43c9c91ff 100644 --- a/src/sourmash/commands.py +++ b/src/sourmash/commands.py @@ -1254,6 +1254,11 @@ def multigather(args): output_base_tracking.add(output_base) + output_matches = output_base + '.matches.sig' + save_sig_obj = SaveSignaturesToLocation(output_matches) + save_sig = save_sig_obj.__enter__() + notify(f"saving all matching signatures to '{output_matches}'") + # write out basic CSV file output_csv = output_base + '.csv' notify(f'saving all CSV matches to "{output_csv}"') @@ -1261,21 +1266,6 @@ def multigather(args): csv_outfp = csv_out_obj.__enter__() csv_writer = None - - ### save matching sketches! - output_matches = output_base + f'.matches{args.extension}' - with SaveSignaturesToLocation(output_matches) as save_sig: - notify(f"saving all matching signatures to '{output_matches}'") - save_sig.add_many([ r.match for r in found ]) - - ### save unassigned hashes! - output_unassigned = output_base + f'.unassigned{args.extension}' - remaining_query = gather_iter.query - if noident_mh: # add hashes with no match in database - remaining_mh = remaining_query.minhash.to_mutable() - remaining_mh += noident_mh.downsample(scaled=remaining_mh.scaled) - remaining_query.minhash = remaining_mh - for result in gather_iter: found += 1 sum_f_uniq_found += result.f_unique_to_query @@ -1358,36 +1348,26 @@ def multigather(args): notify("nothing found... skipping.") continue - output_unassigned = output_base + ".unassigned.sig" - with open(output_unassigned, "w"): - remaining_query = gather_iter.query - if noident_mh: - remaining_mh = remaining_query.minhash.to_mutable() - remaining_mh += noident_mh.downsample(scaled=remaining_mh.scaled) - remaining_query.minhash = remaining_mh + output_unassigned = output_base + f'.unassigned{args.extension}' + remaining_query = gather_iter.query + if noident_mh: + remaining_mh = remaining_query.minhash.to_mutable() + remaining_mh += noident_mh.downsample(scaled=remaining_mh.scaled) + remaining_query.minhash = remaining_mh if is_abundance: abund_query_mh = remaining_query.minhash.inflate(orig_query_mh) remaining_query.minhash = abund_query_mh - # only save if we found matches and there are things to save! - if found and remaining_query: - notify(f'saving unassigned hashes to "{output_unassigned}"') - if found == 0: - notify("nothing found - entire query signature unassigned.") - elif not remaining_query: - notify("no unassigned hashes! not saving.") - else: - notify(f'saving unassigned hashes to "{output_unassigned}"') - - with SaveSignaturesToLocation(output_unassigned) as save_sig: - save_sig.add(remaining_query) - elif not found: - notify('nothing found - entire query signature unassigned.') + if found == 0: + notify("nothing found - entire query signature unassigned.") elif not remaining_query: - notify('no unassigned hashes! not saving.') + notify("no unassigned hashes! not saving.") else: - assert 0, "should be unreachable" + notify(f'saving unassigned hashes to "{output_unassigned}"') + + with SaveSignaturesToLocation(output_unassigned) as save_sig: + save_sig.add(remaining_query) n += 1 From 35897805e187cd2c78695dd5da869169160f7fb3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 28 Feb 2024 14:35:42 +0000 Subject: [PATCH 16/22] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/sourmash/cli/multigather.py | 25 +++++---- src/sourmash/commands.py | 22 ++++---- tests/conftest.py | 2 +- tests/test_sourmash.py | 92 ++++++++++++++++++++------------- 4 files changed, 86 insertions(+), 55 deletions(-) diff --git a/src/sourmash/cli/multigather.py b/src/sourmash/cli/multigather.py index 041ec03421..87842ced94 100644 --- a/src/sourmash/cli/multigather.py +++ b/src/sourmash/cli/multigather.py @@ -80,13 +80,15 @@ def subparser(subparsers): help="stop at databases that contain no compatible signatures", ) subparser.add_argument( - '--force-allow-overwrite-output', action='store_true', - help='allow output files to be overwritten' + "--force-allow-overwrite-output", + action="store_true", + help="allow output files to be overwritten", ) subparser.add_argument( - '--no-fail-on-empty-database', action='store_false', - dest='fail_on_empty_database', - help='continue past databases that contain no compatible signatures' + "--no-fail-on-empty-database", + action="store_false", + dest="fail_on_empty_database", + help="continue past databases that contain no compatible signatures", ) subparser.set_defaults(fail_on_empty_database=True) @@ -96,12 +98,17 @@ def subparser(subparsers): help="output CSV results to this directory", ) subparser.add_argument( - '-U', '--output-add-query-md5sum', action='store_true', - help='add md5sum of each query to ensure unique output file names' + "-U", + "--output-add-query-md5sum", + action="store_true", + help="add md5sum of each query to ensure unique output file names", ) subparser.add_argument( - '-E', '--extension', type=str, default='.sig', - help="write signature files with this extension ('.sig' by default)" + "-E", + "--extension", + type=str, + default=".sig", + help="write signature files with this extension ('.sig' by default)", ) add_ksize_arg(subparser) diff --git a/src/sourmash/commands.py b/src/sourmash/commands.py index d43c9c91ff..142ad31cf8 100644 --- a/src/sourmash/commands.py +++ b/src/sourmash/commands.py @@ -1162,7 +1162,7 @@ def multigather(args): # run gather on all the queries. n = 0 size_may_be_inaccurate = False - output_base_tracking = set() # make sure we are not reusing 'output_base' + output_base_tracking = set() # make sure we are not reusing 'output_base' for queryfile in inp_files: # load the query signature(s) & figure out all the things for query in sourmash_args.load_file_as_signatures( @@ -1229,7 +1229,7 @@ def multigather(args): result = None query_filename = query.filename - if not query_filename or query_filename == '-': + if not query_filename or query_filename == "-": # use md5sum if query.filename not properly set output_base = query.md5sum() elif args.output_add_query_md5sum: @@ -1244,23 +1244,27 @@ def multigather(args): # track overwrites of output files! if output_base in output_base_tracking: - error(f"ERROR: detected overwritten outputs! '{output_base}' has already been used. Failing.") + error( + f"ERROR: detected overwritten outputs! '{output_base}' has already been used. Failing." + ) if args.force_allow_overwrite_output: - error(f"continuing because --force-allow-overwrite was specified") + error("continuing because --force-allow-overwrite was specified") else: - error("Consider using '-U/--output-add-query-md5sum' to build unique outputs") + error( + "Consider using '-U/--output-add-query-md5sum' to build unique outputs" + ) error("and/or '--force-allow-overwrite-output'") sys.exit(-1) output_base_tracking.add(output_base) - output_matches = output_base + '.matches.sig' + output_matches = output_base + ".matches.sig" save_sig_obj = SaveSignaturesToLocation(output_matches) save_sig = save_sig_obj.__enter__() notify(f"saving all matching signatures to '{output_matches}'") # write out basic CSV file - output_csv = output_base + '.csv' + output_csv = output_base + ".csv" notify(f'saving all CSV matches to "{output_csv}"') csv_out_obj = FileOutputCSV(output_csv) csv_outfp = csv_out_obj.__enter__() @@ -1348,7 +1352,7 @@ def multigather(args): notify("nothing found... skipping.") continue - output_unassigned = output_base + f'.unassigned{args.extension}' + output_unassigned = output_base + f".unassigned{args.extension}" remaining_query = gather_iter.query if noident_mh: remaining_mh = remaining_query.minhash.to_mutable() @@ -1374,7 +1378,7 @@ def multigather(args): # fini, next query! # done! report at end. - notify(f'\nconducted gather searches on {n} signatures') + notify(f"\nconducted gather searches on {n} signatures") if size_may_be_inaccurate: notify( "WARNING: size estimation for at least one of these sketches may be inaccurate. ANI values will not be reported for these comparisons." diff --git a/tests/conftest.py b/tests/conftest.py index bf32c8f56a..00476e8a7d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -84,7 +84,7 @@ def sig_save_extension(request): return request.param -@pytest.fixture(params=['sig', 'sig.gz', 'zip', '.d/']) +@pytest.fixture(params=["sig", "sig.gz", "zip", ".d/"]) def sig_save_extension_abund(request): return request.param diff --git a/tests/test_sourmash.py b/tests/test_sourmash.py index 1b4fe1c0c9..2e787402a7 100644 --- a/tests/test_sourmash.py +++ b/tests/test_sourmash.py @@ -4889,7 +4889,7 @@ def test_multigather_metagenome_output(runtmp): print(runtmp.last_result.out) print(runtmp.last_result.err) - output_csv = runtmp.output('b92dbf45dd57867cbec2321ccfa55af8.csv') + output_csv = runtmp.output("b92dbf45dd57867cbec2321ccfa55af8.csv") assert os.path.exists(output_csv) with open(output_csv, newline="") as fp: x = fp.readlines() @@ -4919,7 +4919,7 @@ def test_multigather_metagenome_output_outdir(runtmp): cmd = cmd.split(" ") c.run_sourmash(*cmd) - output_csv = runtmp.output('savehere/b92dbf45dd57867cbec2321ccfa55af8.csv') + output_csv = runtmp.output("savehere/b92dbf45dd57867cbec2321ccfa55af8.csv") assert os.path.exists(output_csv) with open(output_csv, newline="") as fp: x = fp.readlines() @@ -4930,7 +4930,7 @@ def test_multigather_metagenome_query_with_sbt(runtmp): # multigather should work with an SBT as a query c = runtmp - testdata_glob = utils.get_test_data('gather/GCF*.sig') + testdata_glob = utils.get_test_data("gather/GCF*.sig") testdata_sigs = glob.glob(testdata_glob) utils.get_test_data("gather/combined.sig") @@ -4983,7 +4983,7 @@ def test_multigather_metagenome_query_with_lca(runtmp): # make sure that LCA databases can be used as queries c = runtmp - testdata_glob = utils.get_test_data('47*.fa.sig') + testdata_glob = utils.get_test_data("47*.fa.sig") testdata_sigs = glob.glob(testdata_glob) lca_db = utils.get_test_data("lca/47+63.lca.json") @@ -5048,9 +5048,11 @@ def test_multigather_metagenome_query_on_lca_db(runtmp): def test_multigather_metagenome_query_with_sbt_addl_query(runtmp): c = runtmp - testdata_glob = utils.get_test_data('gather/GCF*.sig') + testdata_glob = utils.get_test_data("gather/GCF*.sig") testdata_sigs = glob.glob(testdata_glob) - another_query = utils.get_test_data('gather/GCF_000195995.1_ASM19599v1_genomic.fna.gz.sig') + another_query = utils.get_test_data( + "gather/GCF_000195995.1_ASM19599v1_genomic.fna.gz.sig" + ) testdata_sigs.remove(another_query) @@ -5119,24 +5121,29 @@ def test_multigather_metagenome_query_with_sbt_addl_query_fail_overwrite(runtmp) # provide multiple identical queries - fails c = runtmp - testdata_glob = utils.get_test_data('gather/GCF*.sig') + testdata_glob = utils.get_test_data("gather/GCF*.sig") testdata_sigs = glob.glob(testdata_glob) - another_query = utils.get_test_data('gather/GCF_000195995.1_ASM19599v1_genomic.fna.gz.sig') + another_query = utils.get_test_data( + "gather/GCF_000195995.1_ASM19599v1_genomic.fna.gz.sig" + ) - query_sig = utils.get_test_data('gather/combined.sig') + utils.get_test_data("gather/combined.sig") - cmd = ['index', 'gcf_all.sbt.zip'] + cmd = ["index", "gcf_all.sbt.zip"] cmd.extend(testdata_sigs) - cmd.extend(['-k', '21']) + cmd.extend(["-k", "21"]) c.run_sourmash(*cmd) - assert os.path.exists(c.output('gcf_all.sbt.zip')) - - cmd = 'multigather --query {} {} --db gcf_all.sbt.zip -k 21 --threshold-bp=0'.format(another_query, another_query) - cmd = cmd.split(' ') + assert os.path.exists(c.output("gcf_all.sbt.zip")) + cmd = ( + "multigather --query {} {} --db gcf_all.sbt.zip -k 21 --threshold-bp=0".format( + another_query, another_query + ) + ) + cmd = cmd.split(" ") - with pytest.raises(SourmashCommandFailed) as exc: + with pytest.raises(SourmashCommandFailed): c.run_sourmash(*cmd) out = c.last_result.out @@ -5144,29 +5151,35 @@ def test_multigather_metagenome_query_with_sbt_addl_query_fail_overwrite(runtmp) err = c.last_result.err print(err) - assert "ERROR: detected overwritten outputs! 'GCF_000195995.1_ASM19599v1_genomic.fna.gz' has already been used. Failing." in err + assert ( + "ERROR: detected overwritten outputs! 'GCF_000195995.1_ASM19599v1_genomic.fna.gz' has already been used. Failing." + in err + ) def test_multigather_metagenome_query_with_sbt_addl_query_fail_overwrite_force(runtmp): # provide multiple identical queries - fails -> overwrite with --force c = runtmp - testdata_glob = utils.get_test_data('gather/GCF*.sig') + testdata_glob = utils.get_test_data("gather/GCF*.sig") testdata_sigs = glob.glob(testdata_glob) - another_query = utils.get_test_data('gather/GCF_000195995.1_ASM19599v1_genomic.fna.gz.sig') + another_query = utils.get_test_data( + "gather/GCF_000195995.1_ASM19599v1_genomic.fna.gz.sig" + ) - query_sig = utils.get_test_data('gather/combined.sig') + utils.get_test_data("gather/combined.sig") - cmd = ['index', 'gcf_all.sbt.zip'] + cmd = ["index", "gcf_all.sbt.zip"] cmd.extend(testdata_sigs) - cmd.extend(['-k', '21']) + cmd.extend(["-k", "21"]) c.run_sourmash(*cmd) - assert os.path.exists(c.output('gcf_all.sbt.zip')) - - cmd = 'multigather --query {} {} --db gcf_all.sbt.zip -k 21 --threshold-bp=0 --force-allow-overwrite-output'.format(another_query, another_query) - cmd = cmd.split(' ') + assert os.path.exists(c.output("gcf_all.sbt.zip")) + cmd = "multigather --query {} {} --db gcf_all.sbt.zip -k 21 --threshold-bp=0 --force-allow-overwrite-output".format( + another_query, another_query + ) + cmd = cmd.split(" ") c.run_sourmash(*cmd) @@ -5175,16 +5188,21 @@ def test_multigather_metagenome_query_with_sbt_addl_query_fail_overwrite_force(r err = c.last_result.err print(err) - assert "ERROR: detected overwritten outputs! 'GCF_000195995.1_ASM19599v1_genomic.fna.gz' has already been used. Failing." in err + assert ( + "ERROR: detected overwritten outputs! 'GCF_000195995.1_ASM19599v1_genomic.fna.gz' has already been used. Failing." + in err + ) assert "continuing because --force-allow-overwrite was specified" in err def test_multigather_metagenome_sbt_query_from_file_with_addl_query(runtmp): c = runtmp - testdata_glob = utils.get_test_data('gather/GCF*.sig') + testdata_glob = utils.get_test_data("gather/GCF*.sig") testdata_sigs = glob.glob(testdata_glob) - another_query = utils.get_test_data('gather/GCF_000195995.1_ASM19599v1_genomic.fna.gz.sig') + another_query = utils.get_test_data( + "gather/GCF_000195995.1_ASM19599v1_genomic.fna.gz.sig" + ) testdata_sigs.remove(another_query) @@ -5258,7 +5276,7 @@ def test_multigather_metagenome_sbt_query_from_file_incorrect(runtmp): # use the wrong type of file with --query-from-file c = runtmp - testdata_glob = utils.get_test_data('gather/GCF*.sig') + testdata_glob = utils.get_test_data("gather/GCF*.sig") testdata_sigs = glob.glob(testdata_glob) utils.get_test_data("gather/combined.sig") @@ -5285,7 +5303,7 @@ def test_multigather_metagenome_lca_query_from_file(runtmp): # putting an LCA database in a file for a query should work c = runtmp - testdata_glob = utils.get_test_data('47*.fa.sig') + testdata_glob = utils.get_test_data("47*.fa.sig") testdata_sigs = glob.glob(testdata_glob) lca_db = utils.get_test_data("lca/47+63.lca.json") @@ -5323,7 +5341,7 @@ def test_multigather_metagenome_query_from_file_with_addl_query(runtmp): # test multigather --query-from-file and --query too c = runtmp - testdata_glob = utils.get_test_data('gather/GCF*.sig') + testdata_glob = utils.get_test_data("gather/GCF*.sig") testdata_sigs = glob.glob(testdata_glob) query_sig = utils.get_test_data("gather/combined.sig") @@ -6555,13 +6573,13 @@ def test_gather_empty_db_nofail(runtmp, prefetch_gather, linear_gather): assert "loaded 50 total signatures from 2 locations" in err assert "after selecting signatures compatible with search, 0 remain." in err -def test_multigather_output_unassigned_with_abundance(runtmp, sig_save_extension_abund): +def test_multigather_output_unassigned_with_abundance(runtmp, sig_save_extension_abund): c = runtmp query = utils.get_test_data("gather-abund/reads-s10x10-s11.sig") against = utils.get_test_data("gather-abund/genome-s10.fa.gz.sig") - cmd = 'multigather --query {} --db {} -E {}'.format(query, against, sig_save_extension_abund).split() + cmd = f"multigather --query {query} --db {against} -E {sig_save_extension_abund}".split() c.run_sourmash(*cmd) print(c.last_result.out) @@ -6571,9 +6589,11 @@ def test_multigather_output_unassigned_with_abundance(runtmp, sig_save_extension assert "the recovered matches hit 91.0% of the abundance-weighted query." in out assert "the recovered matches hit 57.2% of the query k-mers (unweighted)." in out - assert os.path.exists(c.output(f'r3.fa.unassigned{sig_save_extension_abund}')) + assert os.path.exists(c.output(f"r3.fa.unassigned{sig_save_extension_abund}")) - nomatch = sourmash.load_file_as_signatures(c.output(f'r3.fa.unassigned{sig_save_extension_abund}')) + nomatch = sourmash.load_file_as_signatures( + c.output(f"r3.fa.unassigned{sig_save_extension_abund}") + ) nomatch = list(nomatch)[0] assert nomatch.minhash.track_abundance From 78788861b41f280152c38b352557dd66e39626b0 Mon Sep 17 00:00:00 2001 From: "C. Titus Brown" Date: Wed, 28 Feb 2024 07:04:22 -0800 Subject: [PATCH 17/22] text fix/cleanup attempt 1 --- tests/test_sourmash.py | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/tests/test_sourmash.py b/tests/test_sourmash.py index 2e787402a7..1a08c23eb8 100644 --- a/tests/test_sourmash.py +++ b/tests/test_sourmash.py @@ -5046,6 +5046,7 @@ def test_multigather_metagenome_query_on_lca_db(runtmp): def test_multigather_metagenome_query_with_sbt_addl_query(runtmp): + # throw in an additional (duplicate) query c = runtmp testdata_glob = utils.get_test_data("gather/GCF*.sig") @@ -5054,10 +5055,6 @@ def test_multigather_metagenome_query_with_sbt_addl_query(runtmp): "gather/GCF_000195995.1_ASM19599v1_genomic.fna.gz.sig" ) - testdata_sigs.remove(another_query) - - utils.get_test_data("gather/combined.sig") - cmd = ["index", "gcf_all.sbt.zip"] cmd.extend(testdata_sigs) cmd.extend(["-k", "21"]) @@ -5065,11 +5062,7 @@ def test_multigather_metagenome_query_with_sbt_addl_query(runtmp): assert os.path.exists(c.output("gcf_all.sbt.zip")) - another_query = utils.get_test_data( - "gather/GCF_000195995.1_ASM19599v1_genomic.fna.gz.sig" - ) - - cmd = "multigather --query {} gcf_all.sbt.zip --db gcf_all.sbt.zip -k 21 --threshold-bp=0".format( + cmd = "multigather --query {} gcf_all.sbt.zip --db gcf_all.sbt.zip -k 21 --threshold-bp=0 --force-allow-overwrite-output".format( another_query ) cmd = cmd.split(" ") @@ -5206,8 +5199,6 @@ def test_multigather_metagenome_sbt_query_from_file_with_addl_query(runtmp): testdata_sigs.remove(another_query) - utils.get_test_data("gather/combined.sig") - cmd = ["index", "gcf_all.sbt.zip"] cmd.extend(testdata_sigs) cmd.extend(["-k", "21"]) @@ -5220,10 +5211,6 @@ def test_multigather_metagenome_sbt_query_from_file_with_addl_query(runtmp): with open(query_list, "w") as fp: print("gcf_all.sbt.zip", file=fp) - another_query = utils.get_test_data( - "gather/GCF_000195995.1_ASM19599v1_genomic.fna.gz.sig" - ) - cmd = "multigather --query {} --query-from-file {} --db gcf_all.sbt.zip -k 21 --threshold-bp=0".format( another_query, query_list ) @@ -5235,7 +5222,7 @@ def test_multigather_metagenome_sbt_query_from_file_with_addl_query(runtmp): err = c.last_result.err print(err) - assert "conducted gather searches on 13 signatures" in err + assert "conducted gather searches on 12 signatures" in err assert "the recovered matches hit 100.0% of the query" in out # check for matches to some of the sbt signatures assert all( From 8b3f708d44841c97a864d6237b04f92d62f7dc09 Mon Sep 17 00:00:00 2001 From: "C. Titus Brown" Date: Thu, 29 Feb 2024 06:53:13 -0800 Subject: [PATCH 18/22] fix multigather test --- tests/test_sourmash.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_sourmash.py b/tests/test_sourmash.py index 1a08c23eb8..84f582f471 100644 --- a/tests/test_sourmash.py +++ b/tests/test_sourmash.py @@ -5189,6 +5189,7 @@ def test_multigather_metagenome_query_with_sbt_addl_query_fail_overwrite_force(r def test_multigather_metagenome_sbt_query_from_file_with_addl_query(runtmp): + # test what happens when we use SBT in a from-file. c = runtmp testdata_glob = utils.get_test_data("gather/GCF*.sig") @@ -5211,8 +5212,8 @@ def test_multigather_metagenome_sbt_query_from_file_with_addl_query(runtmp): with open(query_list, "w") as fp: print("gcf_all.sbt.zip", file=fp) - cmd = "multigather --query {} --query-from-file {} --db gcf_all.sbt.zip -k 21 --threshold-bp=0".format( - another_query, query_list + cmd = "multigather --query {} --query-from-file {} --db gcf_all.sbt.zip {} -k 21 --threshold-bp=0".format( + another_query, query_list, another_query ) cmd = cmd.split(" ") c.run_sourmash(*cmd) From d436bca360f600f7f4856c1e413f574e5f4ad616 Mon Sep 17 00:00:00 2001 From: "C. Titus Brown" Date: Thu, 29 Feb 2024 06:56:40 -0800 Subject: [PATCH 19/22] fix up documentation a bit --- doc/command-line.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/doc/command-line.md b/doc/command-line.md index b8f3cc32d3..4538f4c013 100644 --- a/doc/command-line.md +++ b/doc/command-line.md @@ -349,7 +349,7 @@ information on the different approaches that can be used here.) `sourmash gather` takes exactly one query and one or more [collections of signatures](#storing-and-searching-signatures). Please see -`sourmash multigather` (@CTB link) if you have multiple queries! +[`sourmash multigather`](#sourmash-multigather-do-gather-with-many-queries) if you have multiple queries! If the input signature was created with `-p abund`, output will be abundance weighted (unless `--ignore-abundances` is @@ -564,9 +564,8 @@ query: * `.csv` - gather CSV output * `.matches.sig` - all matching outputs * `.unassigned.sig` - all remaining unassigned hashes -``` -As of sourmash v4.8.4, `` is set as follows: +As of sourmash v4.8.7, `` is set as follows: * the query filename, if it is not empty or `-`; (@CTB is this --query filename or sketch filename?) * the query sketch md5sum, if the query filename is empty or `-`; * the query filename + the query sketch md5sum @@ -577,8 +576,8 @@ By default, `multigather` will complain and exit with an error if the same `` is used repeatedly and an output file is going to be overwritten. With `-U/--output-add-query-md5sum` this should only happen when identical sketches are present in a query -database. Please use `--force-allow-overwrite-output` -to allow overwriting of output files in this case. +database. Use `--force-allow-overwrite-output` +to allow overwriting of output files without an error. ## `sourmash tax` subcommands for integrating taxonomic information into gather results From 4d3059e8a643d9d272a3d672a0c5c297a0f1148b Mon Sep 17 00:00:00 2001 From: "C. Titus Brown" Date: Thu, 29 Feb 2024 06:57:43 -0800 Subject: [PATCH 20/22] fix remaining @CTB --- doc/command-line.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/command-line.md b/doc/command-line.md index 4538f4c013..3279921a36 100644 --- a/doc/command-line.md +++ b/doc/command-line.md @@ -566,7 +566,7 @@ query: * `.unassigned.sig` - all remaining unassigned hashes As of sourmash v4.8.7, `` is set as follows: -* the query filename, if it is not empty or `-`; (@CTB is this --query filename or sketch filename?) +* the filename attribute of the query sketch, if it is not empty or `-`; * the query sketch md5sum, if the query filename is empty or `-`; * the query filename + the query sketch md5sum (`.`), if `-U/--output-add-query-md5sum` is From 839f4ef7c32594ff184db8d88c44442c6657aedd Mon Sep 17 00:00:00 2001 From: "C. Titus Brown" Date: Thu, 29 Feb 2024 07:17:57 -0800 Subject: [PATCH 21/22] add tests for -U --- src/sourmash/commands.py | 2 +- tests/test_sourmash.py | 56 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/sourmash/commands.py b/src/sourmash/commands.py index 142ad31cf8..a96c34dd50 100644 --- a/src/sourmash/commands.py +++ b/src/sourmash/commands.py @@ -1234,7 +1234,7 @@ def multigather(args): output_base = query.md5sum() elif args.output_add_query_md5sum: # Uniquify the output file if all signatures were made from the same file (e.g. with --singleton) - # @CTB check if query_filename is empty. + assert query_filename and query_filename != '-' # first branch output_base = os.path.basename(query_filename) + "." + query.md5sum() else: output_base = os.path.basename(query_filename) diff --git a/tests/test_sourmash.py b/tests/test_sourmash.py index 84f582f471..26ae54f69a 100644 --- a/tests/test_sourmash.py +++ b/tests/test_sourmash.py @@ -5383,6 +5383,62 @@ def test_multigather_metagenome_query_from_file_with_addl_query(runtmp): assert "the recovered matches hit 100.0% of the query" in out +def test_multigather_metagenome_output_unique_empty_filename(runtmp): + # test multigather CSV output with -U/--output-add-query-md5sum + # NOTE: source file of 'combined.sig' is '-' + c = runtmp + testdata_glob = utils.get_test_data("gather/GCF*.sig") + testdata_sigs = glob.glob(testdata_glob) + testdata_sigs_arg = " ".join(testdata_sigs) + + query_sig = utils.get_test_data("gather/combined.sig") + + cmd = f"multigather --query {query_sig} --db {testdata_sigs_arg} -k 21 --threshold-bp=0 -U" + cmd = cmd.split(" ") + c.run_sourmash(*cmd) + + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + output_csv = runtmp.output("b92dbf45dd57867cbec2321ccfa55af8.csv") + assert os.path.exists(output_csv) + with open(output_csv, newline="") as fp: + x = fp.readlines() + assert len(x) == 13 + + +def test_multigather_metagenome_output_unique(runtmp): + # test multigather CSV output with -U/--output-add-query-md5sum + # with a file that has a filename ;) + c = runtmp + testdata_glob = utils.get_test_data("gather/GCF*.sig") + testdata_sigs = glob.glob(testdata_glob) + testdata_sigs_arg = " ".join(testdata_sigs) + + # change 'filename' on 'combined.sig' to something else + orig_query_sig = utils.get_test_data("gather/combined.sig") + sketch = sourmash.load_one_signature(orig_query_sig) + ss = signature.SourmashSignature(sketch.minhash, filename='named_query') + + query_sig = runtmp.output('the_query.sig') + with open(query_sig, "w") as f: + signature.save_signatures([ss], f) + + cmd = f"multigather --query {query_sig} --db {testdata_sigs_arg} -k 21 --threshold-bp=0 -U" + cmd = cmd.split(" ") + c.run_sourmash(*cmd) + + print(runtmp.last_result.out) + print(runtmp.last_result.err) + + # check that output filename has 'named_query' and md5sum in it: + output_csv = runtmp.output("named_query.b92dbf45dd57867cbec2321ccfa55af8.csv") + assert os.path.exists(output_csv) + with open(output_csv, newline="") as fp: + x = fp.readlines() + assert len(x) == 13 + + def test_gather_metagenome_traverse(runtmp, linear_gather, prefetch_gather): # set up a directory $location/gather that contains # everything in the 'tests/test-data/gather' directory From 1265d820fdd27042affbb56565305580f566860c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 29 Feb 2024 15:18:13 +0000 Subject: [PATCH 22/22] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/sourmash/commands.py | 2 +- tests/test_sourmash.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sourmash/commands.py b/src/sourmash/commands.py index a96c34dd50..0b25f34d5e 100644 --- a/src/sourmash/commands.py +++ b/src/sourmash/commands.py @@ -1234,7 +1234,7 @@ def multigather(args): output_base = query.md5sum() elif args.output_add_query_md5sum: # Uniquify the output file if all signatures were made from the same file (e.g. with --singleton) - assert query_filename and query_filename != '-' # first branch + assert query_filename and query_filename != "-" # first branch output_base = os.path.basename(query_filename) + "." + query.md5sum() else: output_base = os.path.basename(query_filename) diff --git a/tests/test_sourmash.py b/tests/test_sourmash.py index 26ae54f69a..fc083a21e5 100644 --- a/tests/test_sourmash.py +++ b/tests/test_sourmash.py @@ -5418,9 +5418,9 @@ def test_multigather_metagenome_output_unique(runtmp): # change 'filename' on 'combined.sig' to something else orig_query_sig = utils.get_test_data("gather/combined.sig") sketch = sourmash.load_one_signature(orig_query_sig) - ss = signature.SourmashSignature(sketch.minhash, filename='named_query') + ss = signature.SourmashSignature(sketch.minhash, filename="named_query") - query_sig = runtmp.output('the_query.sig') + query_sig = runtmp.output("the_query.sig") with open(query_sig, "w") as f: signature.save_signatures([ss], f)