diff --git a/CHANGES.md b/CHANGES.md index f60972ae5..d9e9cae7e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,13 +10,16 @@ * ancestral, refine: Explicitly specify how the root and ambiguous states are handled during sequence reconstruction and mutation counting. [#1690][] (@rneher) * titers: Fix type errors in code associated with cross-validation of models. [#1688][] (@huddlej) -* Add help text to clarify difference in behavior between options that override defaults (e.g. `--metadata-delimiters`) vs. options that extend existing defaults (e.g. `--expected-date-formats`). [#1705][] (@victorlin) * export: The help text for `--lat-longs` has been improved with a link to the defaults and specifics around the overriding behavior. [#1715][] (@victorlin) * augur.io.read_metadata: Pandas versions <1.4.0 prevented this function from properly setting the index column's data type. Support for those older versions has been dropped. [#1716][] (@victorlin) +* In version 24.4.0, one of the new features was that all options that take multiple values could be repeated. Unfortunately, it overlooked a few that have been fixed in this version. [#1707][] (@victorlin) + * `augur curate rename --field-map` + * `augur curate transform-strain-name --backup-fields` +* `augur curate format-dates --expected-date-formats` help text has been improved with clarifications regarding how values provided interact with builtin formats. [#1707][] (@victorlin) [#1688]: https://github.com/nextstrain/augur/pull/1688 [#1690]: https://github.com/nextstrain/augur/pull/1690 -[#1705]: https://github.com/nextstrain/augur/pull/1705 +[#1707]: https://github.com/nextstrain/augur/issues/1707 [#1715]: https://github.com/nextstrain/augur/pull/1715 [#1716]: https://github.com/nextstrain/augur/pull/1716 diff --git a/LICENSE.nextstrain-cli b/LICENSE.nextstrain-cli index 6a5c63890..c8b87f3e8 100644 --- a/LICENSE.nextstrain-cli +++ b/LICENSE.nextstrain-cli @@ -1,8 +1,11 @@ -This license applies to the original copy of resource functions from the -Nextstrain CLI project into this project, incorporated in -"augur/data/__init__.py". Any subsequent modifications to this project's copy of -those functions are licensed under the license of this project, not of -Nextstrain CLI. +This license applies to the original copy of functions from the Nextstrain CLI +project into this project, namely + +- resource functions incorporated as "augur/data/__init__.py" +- walk_commands() function incorporated into "augur/argparse_.py" + +Any subsequent modifications to this project's copy of these functions are +licensed under the license of this project, not of Nextstrain CLI. MIT License diff --git a/augur/align.py b/augur/align.py index 6dc1a002d..4c7d5d99c 100644 --- a/augur/align.py +++ b/augur/align.py @@ -6,6 +6,7 @@ from shutil import copyfile import numpy as np from Bio import AlignIO, SeqIO, Seq, Align +from .argparse_ import ExtendOverwriteDefault from .io.file import open_file from .io.shell_command_runner import run_shell_command from .io.vcf import shquote @@ -24,7 +25,7 @@ def register_arguments(parser): Kept as a separate function than `register_parser` to continue to support unit tests that use this function to create argparser. """ - parser.add_argument('--sequences', '-s', required=True, nargs="+", action="extend", metavar="FASTA", help="sequences to align") + parser.add_argument('--sequences', '-s', required=True, nargs="+", action=ExtendOverwriteDefault, metavar="FASTA", help="sequences to align") parser.add_argument('--output', '-o', default="alignment.fasta", help="output file (default: %(default)s)") parser.add_argument('--nthreads', type=nthreads_value, default=1, help="number of threads to use; specifying the value 'auto' will cause the number of available CPU cores on your system, if determinable, to be used") diff --git a/augur/ancestral.py b/augur/ancestral.py index b1cb84f60..aa4f805f4 100644 --- a/augur/ancestral.py +++ b/augur/ancestral.py @@ -22,6 +22,7 @@ The mutation positions in the node-data JSON are one-based. """ +from augur.argparse_ import ExtendOverwriteDefault from augur.errors import AugurError import sys import numpy as np @@ -317,7 +318,7 @@ def register_parser(parent_subparsers): ) amino_acid_options_group.add_argument('--annotation', help='GenBank or GFF file containing the annotation') - amino_acid_options_group.add_argument('--genes', nargs='+', action='extend', help="genes to translate (list or file containing list)") + amino_acid_options_group.add_argument('--genes', nargs='+', action=ExtendOverwriteDefault, help="genes to translate (list or file containing list)") amino_acid_options_group.add_argument('--translations', type=str, help="translated alignments for each CDS/Gene. " "Currently only supported for FASTA-input. Specify the file name via a " "template like 'aa_sequences_%%GENE.fasta' where %%GENE will be replaced " diff --git a/augur/argparse_.py b/augur/argparse_.py index 291023172..0d1b4d35f 100644 --- a/augur/argparse_.py +++ b/augur/argparse_.py @@ -1,47 +1,26 @@ """ Custom helpers for the argparse standard library. """ -from argparse import Action, ArgumentParser, _ArgumentGroup, HelpFormatter, SUPPRESS, OPTIONAL, ZERO_OR_MORE, _ExtendAction -from typing import Union +from argparse import Action, ArgumentDefaultsHelpFormatter, ArgumentParser, _ArgumentGroup, _SubParsersAction +from itertools import chain +from typing import Iterable, Optional, Tuple, Union from .types import ValidationMode # Include this in an argument help string to suppress the automatic appending -# of the default value by CustomHelpFormatter. This works because the -# automatic appending is conditional on the presence of %(default), so we -# include it but then format it as a zero-length string .0s. 🙃 +# of the default value by argparse.ArgumentDefaultsHelpFormatter. This works +# because the automatic appending is conditional on the presence of %(default), +# so we include it but then format it as a zero-length string .0s. 🙃 # # Another solution would be to add an extra attribute to the argument (the -# argparse.Action instance) and then modify CustomHelpFormatter to condition -# on that new attribute, but that seems more brittle. +# argparse.Action instance) and then subclass ArgumentDefaultsHelpFormatter to +# condition on that new attribute, but that seems more brittle. # -# Initially copied from the Nextstrain CLI repo +# Copied from the Nextstrain CLI repo # https://github.com/nextstrain/cli/blob/017c53805e8317951327d24c04184615cc400b09/nextstrain/cli/argparse.py#L13-L21 SKIP_AUTO_DEFAULT_IN_HELP = "%(default).0s" -class CustomHelpFormatter(HelpFormatter): - """Customize help text. - - Initially copied from argparse.ArgumentDefaultsHelpFormatter. - """ - def _get_help_string(self, action: Action): - help = action.help - - if action.default is not None and action.default != []: - if isinstance(action, ExtendOverwriteDefault): - help += ' Specified values will override the default list.' - if isinstance(action, _ExtendAction): - help += ' Specified values will extend the default list.' - - if '%(default)' not in action.help: - if action.default is not SUPPRESS: - defaulting_nargs = [OPTIONAL, ZERO_OR_MORE] - if action.option_strings or action.nargs in defaulting_nargs: - help += ' (default: %(default)s)' - return help - - def add_default_command(parser): """ Sets the default command to run when none is provided. @@ -83,7 +62,7 @@ def add_command_subparsers(subparsers, commands, command_attribute='__command__' # Use the same formatting class for every command for consistency. # Set here to avoid repeating it in every command's register_parser(). - subparser.formatter_class = CustomHelpFormatter + subparser.formatter_class = ArgumentDefaultsHelpFormatter if not subparser.description and command.__doc__: subparser.description = command.__doc__ @@ -148,3 +127,28 @@ def add_validation_arguments(parser: Union[ArgumentParser, _ArgumentGroup]): action="store_const", const=ValidationMode.SKIP, help="Skip validation of input/output files, equivalent to --validation-mode=skip. Use at your own risk!") + + +# Originally copied from nextstrain/cli/argparse.py in the Nextstrain CLI project¹. +# +# ¹ +def walk_commands(parser: ArgumentParser, command: Optional[Tuple[str, ...]] = None) -> Iterable[Tuple[Tuple[str, ...], ArgumentParser]]: + if command is None: + command = (parser.prog,) + + yield command, parser + + subparsers = chain.from_iterable( + action.choices.items() + for action in parser._actions + if isinstance(action, _SubParsersAction)) + + visited = set() + + for subname, subparser in subparsers: + if subparser in visited: + continue + + visited.add(subparser) + + yield from walk_commands(subparser, (*command, subname)) diff --git a/augur/clades.py b/augur/clades.py index 391de58b5..a26229ac8 100644 --- a/augur/clades.py +++ b/augur/clades.py @@ -18,6 +18,7 @@ from collections import defaultdict import networkx as nx from itertools import islice +from .argparse_ import ExtendOverwriteDefault from .errors import AugurError from .io.file import PANDAS_READ_CSV_OPTIONS from argparse import SUPPRESS @@ -342,8 +343,8 @@ def parse_nodes(tree_file, node_data_files, validation_mode): def register_parser(parent_subparsers): parser = parent_subparsers.add_parser("clades", help=__doc__) parser.add_argument('--tree', required=True, help="prebuilt Newick -- no tree will be built if provided") - parser.add_argument('--mutations', required=True, metavar="NODE_DATA_JSON", nargs='+', action='extend', help='JSON(s) containing ancestral and tip nucleotide and/or amino-acid mutations ') - parser.add_argument('--reference', nargs='+', action='extend', help=SUPPRESS) + parser.add_argument('--mutations', required=True, metavar="NODE_DATA_JSON", nargs='+', action=ExtendOverwriteDefault, help='JSON(s) containing ancestral and tip nucleotide and/or amino-acid mutations ') + parser.add_argument('--reference', nargs='+', action=ExtendOverwriteDefault, help=SUPPRESS) parser.add_argument('--clades', required=True, metavar="TSV", type=str, help='TSV file containing clade definitions by amino-acid') parser.add_argument('--output-node-data', type=str, metavar="NODE_DATA_JSON", help='name of JSON file to save clade assignments to') parser.add_argument('--membership-name', type=str, default="clade_membership", help='Key to store clade membership under; use "None" to not export this') diff --git a/augur/curate/format_dates.py b/augur/curate/format_dates.py index 649992556..bd64f7552 100644 --- a/augur/curate/format_dates.py +++ b/augur/curate/format_dates.py @@ -7,17 +7,18 @@ """ import re from datetime import datetime +from textwrap import dedent -from augur.argparse_ import SKIP_AUTO_DEFAULT_IN_HELP +from augur.argparse_ import ExtendOverwriteDefault, SKIP_AUTO_DEFAULT_IN_HELP from augur.errors import AugurError from augur.io.print import print_err from augur.types import DataErrorMethod from .format_dates_directives import YEAR_DIRECTIVES, YEAR_MONTH_DIRECTIVES, YEAR_MONTH_DAY_DIRECTIVES -# Default date formats that this command should parse +# Builtin date formats that this command should parse # without additional input from the user. -DEFAULT_EXPECTED_DATE_FORMATS = [ +BUILTIN_DATE_FORMATS = [ '%Y-%m-%d', '%Y-%m-XX', '%Y-XX-XX', @@ -31,16 +32,19 @@ def register_parser(parent_subparsers): help=__doc__) required = parser.add_argument_group(title="REQUIRED") - required.add_argument("--date-fields", nargs="+", action="extend", + required.add_argument("--date-fields", nargs="+", action=ExtendOverwriteDefault, help="List of date field names in the record that need to be standardized.") optional = parser.add_argument_group(title="OPTIONAL") - optional.add_argument("--expected-date-formats", nargs="+", action="extend", - default=DEFAULT_EXPECTED_DATE_FORMATS, - help="Expected date formats that are currently in the provided date fields, " + - "defined by standard format codes as listed at " + - "https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes. " + - "If a date string matches multiple formats, it will be parsed as the first matched format in the provided order.") + optional.add_argument("--expected-date-formats", nargs="+", action=ExtendOverwriteDefault, + help=dedent(f"""\ + Custom date formats for values in the provided date fields, defined by standard + format codes available at + . + If a value matches multiple formats, it will be parsed using the first match. + The following formats are builtin and automatically used: + {", ".join(repr(x).replace("%", "%%") for x in BUILTIN_DATE_FORMATS)}. + User-provided values are considered after the builtin formats.""")) optional.add_argument("--failure-reporting", type=DataErrorMethod.argtype, choices=list(DataErrorMethod), @@ -182,10 +186,14 @@ def format_date(date_string, expected_formats): def run(args, records): + expected_date_formats = BUILTIN_DATE_FORMATS + if args.expected_date_formats: + expected_date_formats.extend(args.expected_date_formats) + failures = [] failure_reporting = args.failure_reporting failure_suggestion = ( - f"\nCurrent expected date formats are {args.expected_date_formats!r}. " + + f"\nCurrent expected date formats are {expected_date_formats!r}. " + "This can be updated with --expected-date-formats." ) for index, record in enumerate(records): @@ -198,7 +206,7 @@ def run(args, records): if date_string is None: raise AugurError(f"Expected date field {field!r} not found in record {record_id!r}.") - formatted_date_string = format_date(date_string, args.expected_date_formats) + formatted_date_string = format_date(date_string, expected_date_formats) if formatted_date_string is None: # Mask failed date formatting before processing error methods # to ensure failures are masked even when failures are "silent" diff --git a/augur/curate/rename.py b/augur/curate/rename.py index 68730fa28..af1f68bef 100644 --- a/augur/curate/rename.py +++ b/augur/curate/rename.py @@ -4,6 +4,7 @@ from typing import Iterable, Literal, Union, List, Tuple import argparse +from augur.argparse_ import ExtendOverwriteDefault from augur.io.print import print_err from augur.errors import AugurError @@ -13,7 +14,7 @@ def register_parser(parent_subparsers): help = __doc__) required = parser.add_argument_group(title="REQUIRED") - required.add_argument("--field-map", nargs="+", required=True, + required.add_argument("--field-map", nargs="+", action=ExtendOverwriteDefault, required=True, help="Rename fields/columns via '{old_field_name}={new_field_name}'. " + "If the new field already exists, then the renaming of the old field will be skipped. " + "Multiple entries with the same '{old_field_name}' will duplicate the field/column. " + diff --git a/augur/curate/titlecase.py b/augur/curate/titlecase.py index e0d6017b8..51b2e7274 100644 --- a/augur/curate/titlecase.py +++ b/augur/curate/titlecase.py @@ -4,6 +4,7 @@ import re from typing import Optional, Set, Union +from augur.argparse_ import ExtendOverwriteDefault from augur.errors import AugurError from augur.io.print import print_err from augur.types import DataErrorMethod @@ -14,13 +15,13 @@ def register_parser(parent_subparsers): help = __doc__) required = parser.add_argument_group(title="REQUIRED") - required.add_argument("--titlecase-fields", nargs="*", action="extend", + required.add_argument("--titlecase-fields", nargs="*", action=ExtendOverwriteDefault, help="List of fields to convert to titlecase.", required=True) optional = parser.add_argument_group(title="OPTIONAL") - optional.add_argument("--articles", nargs="*", action="extend", + optional.add_argument("--articles", nargs="*", action=ExtendOverwriteDefault, help="List of articles that should not be converted to titlecase.") - optional.add_argument("--abbreviations", nargs="*", action="extend", + optional.add_argument("--abbreviations", nargs="*", action=ExtendOverwriteDefault, help="List of abbreviations that should not be converted to titlecase, keeps uppercase.") optional.add_argument("--failure-reporting", diff --git a/augur/curate/transform_strain_name.py b/augur/curate/transform_strain_name.py index 6c4f77c30..6c5b33487 100644 --- a/augur/curate/transform_strain_name.py +++ b/augur/curate/transform_strain_name.py @@ -7,6 +7,7 @@ import argparse import re from typing import Generator, List +from augur.argparse_ import ExtendOverwriteDefault from augur.io.print import print_err from augur.utils import first_line @@ -55,6 +56,7 @@ def register_parser( parser.add_argument( "--backup-fields", nargs="*", + action=ExtendOverwriteDefault, default=[], help="List of backup fields to use as strain name if the value in 'strain' " + "does not match the strain regex pattern. " diff --git a/augur/distance.py b/augur/distance.py index d1528ffb5..b5179441b 100644 --- a/augur/distance.py +++ b/augur/distance.py @@ -185,6 +185,7 @@ import pandas as pd import sys +from .argparse_ import ExtendOverwriteDefault from .frequency_estimators import timestamp_to_float from .io.file import open_file from .reconstruct_sequences import load_alignments @@ -660,11 +661,11 @@ def get_distances_to_all_pairs(tree, sequences_by_node_and_gene, distance_map, e def register_parser(parent_subparsers): parser = parent_subparsers.add_parser("distance", help=first_line(__doc__)) parser.add_argument("--tree", help="Newick tree", required=True) - parser.add_argument("--alignment", nargs="+", action="extend", help="sequence(s) to be used, supplied as FASTA files", required=True) - parser.add_argument('--gene-names', nargs="+", action="extend", type=str, help="names of the sequences in the alignment, same order assumed", required=True) - parser.add_argument("--attribute-name", nargs="+", action="extend", help="name to store distances associated with the given distance map; multiple attribute names are linked to corresponding positional comparison method and distance map arguments", required=True) - parser.add_argument("--compare-to", nargs="+", action="extend", choices=["root", "ancestor", "pairwise"], help="type of comparison between samples in the given tree including comparison of all nodes to the root (root), all tips to their last ancestor from a previous season (ancestor), or all tips from the current season to all tips in previous seasons (pairwise)", required=True) - parser.add_argument("--map", nargs="+", action="extend", help="JSON providing the distance map between sites and, optionally, sequences present at those sites; the distance map JSON minimally requires a 'default' field defining a default numeric distance and a 'map' field defining a dictionary of genes and one-based coordinates", required=True) + parser.add_argument("--alignment", nargs="+", action=ExtendOverwriteDefault, help="sequence(s) to be used, supplied as FASTA files", required=True) + parser.add_argument('--gene-names', nargs="+", action=ExtendOverwriteDefault, type=str, help="names of the sequences in the alignment, same order assumed", required=True) + parser.add_argument("--attribute-name", nargs="+", action=ExtendOverwriteDefault, help="name to store distances associated with the given distance map; multiple attribute names are linked to corresponding positional comparison method and distance map arguments", required=True) + parser.add_argument("--compare-to", nargs="+", action=ExtendOverwriteDefault, choices=["root", "ancestor", "pairwise"], help="type of comparison between samples in the given tree including comparison of all nodes to the root (root), all tips to their last ancestor from a previous season (ancestor), or all tips from the current season to all tips in previous seasons (pairwise)", required=True) + parser.add_argument("--map", nargs="+", action=ExtendOverwriteDefault, help="JSON providing the distance map between sites and, optionally, sequences present at those sites; the distance map JSON minimally requires a 'default' field defining a default numeric distance and a 'map' field defining a dictionary of genes and one-based coordinates", required=True) parser.add_argument("--date-annotations", help="JSON of branch lengths and date annotations from augur refine for samples in the given tree; required for comparisons to earliest or latest date") parser.add_argument("--earliest-date", help="earliest date at which samples are considered to be from previous seasons (e.g., 2019-01-01). This date is only used in pairwise comparisons. If omitted, all samples prior to the latest date will be considered.") parser.add_argument("--latest-date", help="latest date at which samples are considered to be from previous seasons (e.g., 2019-01-01); samples from any date after this are considered part of the current season") diff --git a/augur/export_v1.py b/augur/export_v1.py index 901775dcc..07a1c2024 100644 --- a/augur/export_v1.py +++ b/augur/export_v1.py @@ -315,7 +315,7 @@ def add_core_args(parser): core.add_argument('--metadata', required=True, metavar="FILE", help="sequence metadata") core.add_argument('--metadata-delimiters', default=DEFAULT_DELIMITERS, nargs="+", action=ExtendOverwriteDefault, help="delimiters to accept when reading a metadata file. Only one delimiter will be inferred.") - core.add_argument('--node-data', required=True, nargs='+', action="extend", help="JSON files with meta data for each node") + core.add_argument('--node-data', required=True, nargs='+', action=ExtendOverwriteDefault, help="JSON files with meta data for each node") core.add_argument('--output-tree', help="JSON file name that is passed on to auspice (e.g., zika_tree.json).") core.add_argument('--output-meta', help="JSON file name that is passed on to auspice (e.g., zika_meta.json).") core.add_argument('--auspice-config', help="file with auspice configuration") diff --git a/augur/export_v2.py b/augur/export_v2.py index 4d5a9dc93..e010e0a6f 100644 --- a/augur/export_v2.py +++ b/augur/export_v2.py @@ -945,20 +945,20 @@ def register_parser(parent_subparsers): ) config.add_argument('--auspice-config', metavar="JSON", help="Auspice configuration file") config.add_argument('--title', type=str, metavar="title", help="Title to be displayed by auspice") - config.add_argument('--maintainers', metavar="name", action="append", nargs='+', help="Analysis maintained by, in format 'Name ' 'Name2 ', ...") + config.add_argument('--maintainers', metavar="name", action=ExtendOverwriteDefault, nargs='+', help="Analysis maintained by, in format 'Name ' 'Name2 ', ...") config.add_argument('--build-url', type=str, metavar="url", help="Build URL/repository to be displayed by Auspice") config.add_argument('--description', metavar="description.md", help="Markdown file with description of build and/or acknowledgements to be displayed by Auspice") - config.add_argument('--geo-resolutions', metavar="trait", nargs='+', action='extend', help="Geographic traits to be displayed on map") - config.add_argument('--color-by-metadata', metavar="trait", nargs='+', action='extend', help="Metadata columns to include as coloring options") - config.add_argument('--metadata-columns', nargs="+", action="extend", + config.add_argument('--geo-resolutions', metavar="trait", nargs='+', action=ExtendOverwriteDefault, help="Geographic traits to be displayed on map") + config.add_argument('--color-by-metadata', metavar="trait", nargs='+', action=ExtendOverwriteDefault, help="Metadata columns to include as coloring options") + config.add_argument('--metadata-columns', nargs="+", action=ExtendOverwriteDefault, help="Metadata columns to export in addition to columns provided by --color-by-metadata or colorings in the Auspice configuration file. " + "These columns will not be used as coloring options in Auspice but will be visible in the tree.") - config.add_argument('--panels', metavar="panels", nargs='+', action='extend', choices=['tree', 'map', 'entropy', 'frequencies', 'measurements'], help="Restrict panel display in auspice. Options are %(choices)s. Ignore this option to display all available panels.") + config.add_argument('--panels', metavar="panels", nargs='+', action=ExtendOverwriteDefault, choices=['tree', 'map', 'entropy', 'frequencies', 'measurements'], help="Restrict panel display in auspice. Options are %(choices)s. Ignore this option to display all available panels.") optional_inputs = parser.add_argument_group( title="OPTIONAL INPUT FILES" ) - optional_inputs.add_argument('--node-data', metavar="JSON", nargs='+', action="extend", help="JSON files containing metadata for nodes in the tree") + optional_inputs.add_argument('--node-data', metavar="JSON", nargs='+', action=ExtendOverwriteDefault, help="JSON files containing metadata for nodes in the tree") optional_inputs.add_argument('--metadata', metavar="FILE", help="Additional metadata for strains in the tree") optional_inputs.add_argument('--metadata-delimiters', default=DEFAULT_DELIMITERS, nargs="+", action=ExtendOverwriteDefault, help="delimiters to accept when reading a metadata file. Only one delimiter will be inferred.") @@ -1034,20 +1034,17 @@ def set_display_defaults(data_json, config): def set_maintainers(data_json, config, cmd_line_maintainers): # Command-line args overwrite the config file - # Command-line info could come in as multiple lists w/multiple values, ex: - # [['Name1 '], ['Name2 ', 'Name3 '], ['Name4 ']] # They may or may not all have URLs if cmd_line_maintainers: maintainers = [] - for arg_entry in cmd_line_maintainers: - for maint in arg_entry: - res = re.search('<(.*)>', maint) - url = res.group(1) if res else '' - name = maint.split("<")[0].strip() - tmp_dict = {'name': name} - if url: - tmp_dict['url'] = url - maintainers.append(tmp_dict) + for maint in cmd_line_maintainers: + res = re.search('<(.*)>', maint) + url = res.group(1) if res else '' + name = maint.split("<")[0].strip() + tmp_dict = {'name': name} + if url: + tmp_dict['url'] = url + maintainers.append(tmp_dict) data_json['meta']['maintainers'] = maintainers elif config.get("maintainer"): # v1-type specification data_json['meta']["maintainers"] = [{ "name": config["maintainer"][0], "url": config["maintainer"][1]}] diff --git a/augur/filter/__init__.py b/augur/filter/__init__.py index 2c6c9d7db..25e954a5e 100644 --- a/augur/filter/__init__.py +++ b/augur/filter/__init__.py @@ -30,7 +30,7 @@ def register_arguments(parser): Uses Pandas Dataframe querying, see https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#indexing-query for syntax. (e.g., --query "country == 'Colombia'" or --query "(country == 'USA' & (division == 'Washington'))")""" ) - metadata_filter_group.add_argument('--query-columns', type=column_type_pair, nargs="+", action="extend", help=f""" + metadata_filter_group.add_argument('--query-columns', type=column_type_pair, nargs="+", action=ExtendOverwriteDefault, help=f""" Use alongside --query to specify columns and data types in the format 'column:type', where type is one of ({','.join(ACCEPTED_TYPES)}). Automatic type inference will be attempted on all unspecified columns used in the query. Example: region:str coverage:float. @@ -39,12 +39,12 @@ def register_arguments(parser): metadata_filter_group.add_argument('--max-date', type=numeric_date_type, help=f"maximal cutoff for date, the cutoff date is inclusive; may be specified as: {SUPPORTED_DATE_HELP_TEXT}") metadata_filter_group.add_argument('--exclude-ambiguous-dates-by', choices=['any', 'day', 'month', 'year'], help='Exclude ambiguous dates by day (e.g., 2020-09-XX), month (e.g., 2020-XX-XX), year (e.g., 200X-10-01), or any date fields. An ambiguous year makes the corresponding month and day ambiguous, too, even if those fields have unambiguous values (e.g., "201X-10-01"). Similarly, an ambiguous month makes the corresponding day ambiguous (e.g., "2010-XX-01").') - metadata_filter_group.add_argument('--exclude', type=str, nargs="+", action="extend", help="file(s) with list of strains to exclude") - metadata_filter_group.add_argument('--exclude-where', nargs='+', action='extend', + metadata_filter_group.add_argument('--exclude', type=str, nargs="+", action=ExtendOverwriteDefault, help="file(s) with list of strains to exclude") + metadata_filter_group.add_argument('--exclude-where', nargs='+', action=ExtendOverwriteDefault, help="Exclude samples matching these conditions. Ex: \"host=rat\" or \"host!=rat\". Multiple values are processed as OR (matching any of those specified will be excluded), not AND") metadata_filter_group.add_argument('--exclude-all', action="store_true", help="exclude all strains by default. Use this with the include arguments to select a specific subset of strains.") - metadata_filter_group.add_argument('--include', type=str, nargs="+", action="extend", help="file(s) with list of strains to include regardless of priorities, subsampling, or absence of an entry in --sequences.") - metadata_filter_group.add_argument('--include-where', nargs='+', action='extend', help=""" + metadata_filter_group.add_argument('--include', type=str, nargs="+", action=ExtendOverwriteDefault, help="file(s) with list of strains to include regardless of priorities, subsampling, or absence of an entry in --sequences.") + metadata_filter_group.add_argument('--include-where', nargs='+', action=ExtendOverwriteDefault, help=""" Include samples with these values. ex: host=rat. Multiple values are processed as OR (having any of those specified will be included), not AND. This rule is applied last and ensures any strains matching these @@ -57,7 +57,7 @@ def register_arguments(parser): sequence_filter_group.add_argument('--non-nucleotide', action='store_true', help="exclude sequences that contain illegal characters") subsample_group = parser.add_argument_group("subsampling", "options to subsample filtered data") - subsample_group.add_argument('--group-by', nargs='+', action='extend', default=[], help=f""" + subsample_group.add_argument('--group-by', nargs='+', action=ExtendOverwriteDefault, default=[], help=f""" categories with respect to subsample. Notes: (1) Grouping by {sorted(constants.GROUP_BY_GENERATED_COLUMNS)} is only supported when there is a {METADATA_DATE_COLUMN!r} column in the metadata. diff --git a/augur/frequencies.py b/augur/frequencies.py index b0abfb397..859c65dce 100644 --- a/augur/frequencies.py +++ b/augur/frequencies.py @@ -49,9 +49,9 @@ def register_parser(parent_subparsers): help="calculate frequencies for internal nodes as well as tips") # Alignment-specific arguments - parser.add_argument('--alignments', type=str, nargs='+', action='extend', + parser.add_argument('--alignments', type=str, nargs='+', action=ExtendOverwriteDefault, help="alignments to estimate mutations frequencies for") - parser.add_argument('--gene-names', nargs='+', action='extend', type=str, + parser.add_argument('--gene-names', nargs='+', action=ExtendOverwriteDefault, type=str, help="names of the sequences in the alignment, same order assumed") parser.add_argument('--ignore-char', type=str, default='', help="character to be ignored in frequency calculations") diff --git a/augur/lbi.py b/augur/lbi.py index b7063ec3a..83b66e32c 100644 --- a/augur/lbi.py +++ b/augur/lbi.py @@ -5,6 +5,7 @@ from collections import defaultdict import json import numpy as np +from .argparse_ import ExtendOverwriteDefault from .io.file import open_file from .utils import write_json @@ -85,9 +86,9 @@ def register_parser(parent_subparsers): parser.add_argument("--tree", help="Newick tree", required=True) parser.add_argument("--branch-lengths", help="JSON with branch lengths and internal node dates estimated by TreeTime", required=True) parser.add_argument("--output", help="JSON file with calculated distances stored by node name and attribute name", required=True) - parser.add_argument("--attribute-names", nargs="+", action="extend", help="names to store distances associated with the corresponding masks", required=True) - parser.add_argument("--tau", nargs="+", action="extend", type=float, help="tau value(s) defining the neighborhood of each clade", required=True) - parser.add_argument("--window", nargs="+", action="extend", type=float, help="time window(s) to calculate LBI across", required=True) + parser.add_argument("--attribute-names", nargs="+", action=ExtendOverwriteDefault, help="names to store distances associated with the corresponding masks", required=True) + parser.add_argument("--tau", nargs="+", action=ExtendOverwriteDefault, type=float, help="tau value(s) defining the neighborhood of each clade", required=True) + parser.add_argument("--window", nargs="+", action=ExtendOverwriteDefault, type=float, help="time window(s) to calculate LBI across", required=True) parser.add_argument("--no-normalization", action="store_true", help="disable normalization of LBI by the maximum value") return parser diff --git a/augur/mask.py b/augur/mask.py index d7f880ffe..75d771f46 100644 --- a/augur/mask.py +++ b/augur/mask.py @@ -7,6 +7,7 @@ from Bio.Seq import MutableSeq +from .argparse_ import ExtendOverwriteDefault from .io.file import open_file from .io.sequences import read_sequences, write_sequences from .io.shell_command_runner import run_shell_command @@ -176,7 +177,7 @@ def register_arguments(parser): parser.add_argument('--mask-from-beginning', type=int, default=0, help="FASTA Only: Number of sites to mask from beginning") parser.add_argument('--mask-from-end', type=int, default=0, help="FASTA Only: Number of sites to mask from end") parser.add_argument('--mask-invalid', action='store_true', help="FASTA Only: Mask invalid nucleotides") - parser.add_argument("--mask-sites", nargs='+', action='extend', type = int, help="1-indexed list of sites to mask") + parser.add_argument("--mask-sites", nargs='+', action=ExtendOverwriteDefault, type = int, help="1-indexed list of sites to mask") parser.add_argument('--output', '-o', help="output file") parser.add_argument('--no-cleanup', dest="cleanup", action="store_false", help="Leave intermediate files around. May be useful for debugging") diff --git a/augur/measurements/concat.py b/augur/measurements/concat.py index 28a9dd865..24d2cddf5 100644 --- a/augur/measurements/concat.py +++ b/augur/measurements/concat.py @@ -3,6 +3,7 @@ """ import sys +from augur.argparse_ import ExtendOverwriteDefault from augur.utils import first_line, write_json from augur.validate import ( measurements as read_measurements_json, @@ -16,7 +17,7 @@ def register_parser(parent_subparsers): required = parser.add_argument_group( title="REQUIRED" ) - required.add_argument("--jsons", required=True, type=str, nargs="+", action="extend", metavar="JSONs", + required.add_argument("--jsons", required=True, type=str, nargs="+", action=ExtendOverwriteDefault, metavar="JSONs", help="Measurement JSON files to concatenate.") required.add_argument("--output-json", required=True, metavar="JSON", type=str, help="Output JSON file") diff --git a/augur/measurements/export.py b/augur/measurements/export.py index 2dfd83bcb..bb6dd1ad5 100644 --- a/augur/measurements/export.py +++ b/augur/measurements/export.py @@ -5,7 +5,7 @@ import pandas as pd import sys -from augur.argparse_ import HideAsFalseAction +from augur.argparse_ import ExtendOverwriteDefault, HideAsFalseAction from augur.io.file import PANDAS_READ_CSV_OPTIONS from augur.utils import first_line, write_json from augur.validate import ( @@ -55,7 +55,7 @@ def register_parser(parent_subparsers): ) config.add_argument("--collection-config", metavar="JSON", help="Collection configuration file for advanced configurations. ") - config.add_argument("--grouping-column", nargs="+", action="extend", + config.add_argument("--grouping-column", nargs="+", action=ExtendOverwriteDefault, help="Name of the column(s) that should be used as grouping(s) for measurements. " + "Note that if groupings are provided via command line args, the default group-by " + "field in the config JSON will be dropped.") @@ -69,9 +69,9 @@ def register_parser(parent_subparsers): help="The short label to display for the x-axis that describles the value of the measurements. " + "If not provided via config or command line option, the panel's default " + f"x-axis label is {DEFAULT_ARGS['x_axis_label']!r}.") - config.add_argument("--thresholds", type=float, nargs="+", action="extend", + config.add_argument("--thresholds", type=float, nargs="+", action=ExtendOverwriteDefault, help="Measurements value threshold(s) to be displayed in the measurements panel.") - config.add_argument("--filters", nargs="+", action="extend", + config.add_argument("--filters", nargs="+", action=ExtendOverwriteDefault, help="The columns that are to be used a filters for measurements. " + "If not provided, all columns will be available as filters.") config.add_argument("--group-by", type=str, @@ -89,7 +89,7 @@ def register_parser(parent_subparsers): optional = parser.add_argument_group( title="OPTIONAL SETTINGS" ) - optional.add_argument("--include-columns", nargs="+", action="extend", + optional.add_argument("--include-columns", nargs="+", action=ExtendOverwriteDefault, help="The columns to include from the collection TSV in the measurements JSON. " + "Be sure to list columns that are used as groupings and/or filters. " + "If no columns are provided, then all columns will be included by default.") diff --git a/augur/merge.py b/augur/merge.py index 8a66123cb..0168fe797 100644 --- a/augur/merge.py +++ b/augur/merge.py @@ -77,7 +77,7 @@ def register_parser(parent_subparsers): parser = parent_subparsers.add_parser("merge", help=first_line(__doc__)) input_group = parser.add_argument_group("inputs", "options related to input") - input_group.add_argument("--metadata", nargs="+", action="extend", required=True, metavar="NAME=FILE", help="Required. Metadata table names and file paths. Names are arbitrary monikers used solely for referring to the associated input file in other arguments and in output column names. Paths must be to seekable files, not unseekable streams. Compressed files are supported." + SKIP_AUTO_DEFAULT_IN_HELP) + input_group.add_argument("--metadata", nargs="+", action=ExtendOverwriteDefault, required=True, metavar="NAME=FILE", help="Required. Metadata table names and file paths. Names are arbitrary monikers used solely for referring to the associated input file in other arguments and in output column names. Paths must be to seekable files, not unseekable streams. Compressed files are supported." + SKIP_AUTO_DEFAULT_IN_HELP) input_group.add_argument("--metadata-id-columns", default=DEFAULT_ID_COLUMNS, nargs="+", action=ExtendOverwriteDefault, metavar="[TABLE=]COLUMN", help=f"Possible metadata column names containing identifiers, considered in the order given. Columns will be considered for all metadata tables by default. Table-specific column names may be given using the same names assigned in --metadata. Only one ID column will be inferred for each table. (default: {' '.join(map(shquote_humanized, DEFAULT_ID_COLUMNS))})" + SKIP_AUTO_DEFAULT_IN_HELP) input_group.add_argument("--metadata-delimiters", default=DEFAULT_DELIMITERS, nargs="+", action=ExtendOverwriteDefault, metavar="[TABLE=]CHARACTER", help=f"Possible field delimiters to use for reading metadata tables, considered in the order given. Delimiters will be considered for all metadata tables by default. Table-specific delimiters may be given using the same names assigned in --metadata. Only one delimiter will be inferred for each table. (default: {' '.join(map(shquote_humanized, DEFAULT_DELIMITERS))})" + SKIP_AUTO_DEFAULT_IN_HELP) diff --git a/augur/parse.py b/augur/parse.py index 6e6a33ebb..2b9e30520 100644 --- a/augur/parse.py +++ b/augur/parse.py @@ -6,6 +6,7 @@ import sys from typing import Dict, Sequence, Tuple +from .argparse_ import ExtendOverwriteDefault from .io.file import open_file from .io.sequences import read_sequences, write_sequences from .dates import get_numerical_date_from_value @@ -164,8 +165,8 @@ def register_parser(parent_subparsers): parser.add_argument('--output-metadata', required=True, help="output metadata file") parser.add_argument('--output-id-field', required=False, help=f"The record field to use as the sequence identifier in the FASTA output. If not provided, this will use the first available of {PARSE_DEFAULT_ID_COLUMNS}. If none of those are available, this will use the first field in the fasta header.") - parser.add_argument('--fields', required=True, nargs='+', action='extend', help="fields in fasta header") - parser.add_argument('--prettify-fields', nargs='+', action='extend', help="apply string prettifying operations (underscores to spaces, capitalization, etc) to specified metadata fields") + parser.add_argument('--fields', required=True, nargs='+', action=ExtendOverwriteDefault, help="fields in fasta header") + parser.add_argument('--prettify-fields', nargs='+', action=ExtendOverwriteDefault, help="apply string prettifying operations (underscores to spaces, capitalization, etc) to specified metadata fields") parser.add_argument('--separator', default='|', help="separator of fasta header") parser.add_argument('--fix-dates', choices=['dayfirst', 'monthfirst'], help="attempt to parse non-standard dates and output them in standard YYYY-MM-DD format") diff --git a/augur/refine.py b/augur/refine.py index c5cbdfe67..e7f03e939 100644 --- a/augur/refine.py +++ b/augur/refine.py @@ -139,7 +139,7 @@ def register_parser(parent_subparsers): parser.add_argument('--clock-filter-iqd', type=float, help='clock-filter: remove tips that deviate more than n_iqd ' 'interquartile ranges from the root-to-tip vs time regression') parser.add_argument('--vcf-reference', type=str, help='fasta file of the sequence the VCF was mapped to') - parser.add_argument('--year-bounds', type=int, nargs='+', action='extend', help='specify min or max & min prediction bounds for samples with XX in year') + parser.add_argument('--year-bounds', type=int, nargs='+', action=ExtendOverwriteDefault, help='specify min or max & min prediction bounds for samples with XX in year') parser.add_argument('--divergence-units', type=str, choices=['mutations', 'mutations-per-site'], default='mutations-per-site', help='Units in which sequence divergences is exported.') parser.add_argument('--seed', type=int, help='seed for random number generation') diff --git a/augur/titers.py b/augur/titers.py index 2b06fb468..1e966e3e9 100644 --- a/augur/titers.py +++ b/augur/titers.py @@ -8,7 +8,7 @@ from .reconstruct_sequences import load_alignments from .titer_model import InsufficientDataException from .utils import write_json -from .argparse_ import add_default_command +from .argparse_ import add_default_command, ExtendOverwriteDefault def register_parser(parent_subparsers): @@ -17,7 +17,7 @@ def register_parser(parent_subparsers): add_default_command(parser) tree_model = subparsers.add_parser('tree', help='tree model') - tree_model.add_argument('--titers', nargs='+', action='extend', type=str, required=True, help="file with titer measurements") + tree_model.add_argument('--titers', nargs='+', action=ExtendOverwriteDefault, type=str, required=True, help="file with titer measurements") tree_model.add_argument('--tree', '-t', type=str, required=True, help="tree to perform fit titer model to") tree_model.add_argument('--allow-empty-model', action="store_true", help="allow model to be empty") tree_model.add_argument('--attribute-prefix', default="", help="prefix for node attributes in the JSON output including cumulative titer drop ('cTiter') and per-branch titer drop ('dTiter'). Set a prefix to disambiguate annotations from multiple tree model JSONs in the final Auspice JSON.") @@ -27,9 +27,9 @@ def register_parser(parent_subparsers): ) sub_model = subparsers.add_parser('sub', help='substitution model') - sub_model.add_argument('--titers', nargs='+', action='extend', type=str, required=True, help="file with titer measurements") - sub_model.add_argument('--alignment', nargs='+', action='extend', type=str, required=True, help="sequence to be used in the substitution model, supplied as fasta files") - sub_model.add_argument('--gene-names', nargs='+', action='extend', type=str, required=True, help="names of the sequences in the alignment, same order assumed") + sub_model.add_argument('--titers', nargs='+', action=ExtendOverwriteDefault, type=str, required=True, help="file with titer measurements") + sub_model.add_argument('--alignment', nargs='+', action=ExtendOverwriteDefault, type=str, required=True, help="sequence to be used in the substitution model, supplied as fasta files") + sub_model.add_argument('--gene-names', nargs='+', action=ExtendOverwriteDefault, type=str, required=True, help="names of the sequences in the alignment, same order assumed") sub_model.add_argument('--tree', '-t', type=str, help="optional tree to annotate fit titer model to") sub_model.add_argument('--allow-empty-model', action="store_true", help="allow model to be empty") sub_model.add_argument('--attribute-prefix', default="", help="prefix for node attributes in the JSON output including cumulative titer drop ('cTiterSub') and per-substitution titer drop ('dTiterSub'). Set a prefix to disambiguate annotations from multiple substitution model JSONs in the final Auspice JSON.") diff --git a/augur/traits.py b/augur/traits.py index 595c6343c..a21467989 100644 --- a/augur/traits.py +++ b/augur/traits.py @@ -106,7 +106,7 @@ def register_parser(parent_subparsers): parser.add_argument('--metadata-id-columns', default=DEFAULT_ID_COLUMNS, nargs="+", action=ExtendOverwriteDefault, help="names of possible metadata columns containing identifier information, ordered by priority. Only one ID column will be inferred.") parser.add_argument('--weights', required=False, help="tsv/csv table with equilibrium probabilities of discrete states") - parser.add_argument('--columns', required=True, nargs='+', action='extend', + parser.add_argument('--columns', required=True, nargs='+', action=ExtendOverwriteDefault, help='metadata fields to perform discrete reconstruction on') parser.add_argument('--confidence',action="store_true", help='record the distribution of subleading mugration states') diff --git a/augur/translate.py b/augur/translate.py index 637759e99..04fc6f573 100644 --- a/augur/translate.py +++ b/augur/translate.py @@ -22,7 +22,7 @@ from treetime.vcf_utils import read_vcf from augur.errors import AugurError from textwrap import dedent -from .argparse_ import add_validation_arguments +from .argparse_ import add_validation_arguments, ExtendOverwriteDefault from .util_support.node_data_file import NodeDataObject class MissingNodeError(Exception): @@ -367,7 +367,7 @@ def register_parser(parent_subparsers): parser.add_argument('--ancestral-sequences', required=True, type=str, help='JSON (fasta input) or VCF (VCF input) containing ancestral and tip sequences') parser.add_argument('--reference-sequence', required=True, help='GenBank or GFF file containing the annotation') - parser.add_argument('--genes', nargs='+', action='extend', help="genes to translate (list or file containing list)") + parser.add_argument('--genes', nargs='+', action=ExtendOverwriteDefault, help="genes to translate (list or file containing list)") parser.add_argument('--output-node-data', type=str, help='name of JSON file to save aa-mutations to') parser.add_argument('--alignment-output', type=str, help="write out translated gene alignments. " "If a VCF-input, a .vcf or .vcf.gz will be output here (depending on file ending). If fasta-input, specify the file name " diff --git a/docs/conf.py b/docs/conf.py index 12b6e4f10..7b439c465 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -132,10 +132,9 @@ def prose_list(items): ("py:class", "json.decoder.JSONDecodeError"), ("py:class", "json.encoder.JSONEncoder"), - # These classes can't be referenced. + # This class can't be referenced. # ("py:class", "argparse._SubParsersAction"), - ("py:class", "argparse.HelpFormatter"), ] # -- Cross-project references ------------------------------------------------ diff --git a/scripts/diff_jsons.py b/scripts/diff_jsons.py index 4d0ffafb4..ff9ef2a2b 100644 --- a/scripts/diff_jsons.py +++ b/scripts/diff_jsons.py @@ -16,7 +16,7 @@ parser.add_argument("second_json", help="second JSON to compare") parser.add_argument("--significant-digits", type=int, default=5, help="number of significant digits to use when comparing numeric values") parser.add_argument("--exclude-paths", nargs="+", action=ExtendOverwriteDefault, help="list of paths to exclude from consideration when performing a diff", default=["root['generated_by']['version']"]) - parser.add_argument("--exclude-regex-paths", nargs="+", action="extend", help="list of path regular expressions to exclude from consideration when performing a diff") + parser.add_argument("--exclude-regex-paths", nargs="+", action=ExtendOverwriteDefault, help="list of path regular expressions to exclude from consideration when performing a diff") parser.add_argument("--ignore-numeric-type-changes", action="store_true", help="ignore numeric type changes in the diff (e.g., int of 1 to float of 1.0)") args = parser.parse_args() diff --git a/scripts/s3.py b/scripts/s3.py index 3fbd9c760..62c800543 100644 --- a/scripts/s3.py +++ b/scripts/s3.py @@ -16,6 +16,7 @@ --to production-data --prefixes flu_h3n2 """ import argparse, boto3, botocore, glob, gzip, io, logging, os, shutil, time +from augur.argparse_ import ExtendOverwriteDefault # Map S3 buckets to their corresponding CloudFront ids. @@ -271,14 +272,14 @@ def sync(source_bucket_name, destination_bucket_name, prefixes=None, dryrun=Fals parser_pull = subparsers.add_parser("pull") parser_pull.add_argument("--bucket", "-b", type=str, help="S3 bucket to pull files from") - parser_pull.add_argument("--prefixes", "-p", nargs="+", action="extend", help="One or more file prefixes to match in the given bucket") + parser_pull.add_argument("--prefixes", "-p", nargs="+", action=ExtendOverwriteDefault, help="One or more file prefixes to match in the given bucket") parser_pull.add_argument("--local_dir", "--to", "-t", help="Local directory to download files into") parser_pull.set_defaults(func=pull) parser_sync = subparsers.add_parser("sync") parser_sync.add_argument("--source_bucket", "--from", type=str, help="Source S3 bucket") parser_sync.add_argument("--destination_bucket", "--to", type=str, help="Destination S3 bucket") - parser_sync.add_argument("--prefixes", "-p", nargs="+", action="extend", help="One or more prefixes for files to sync between buckets") + parser_sync.add_argument("--prefixes", "-p", nargs="+", action=ExtendOverwriteDefault, help="One or more prefixes for files to sync between buckets") parser_sync.set_defaults(func=sync) args = parser.parse_args() diff --git a/scripts/traits_from_json.py b/scripts/traits_from_json.py index 25b3d681d..6527b6859 100644 --- a/scripts/traits_from_json.py +++ b/scripts/traits_from_json.py @@ -30,7 +30,7 @@ def get_trait(attributes, trait, dateFormat): parser = argparse.ArgumentParser(description = "Process a given JSONs") parser.add_argument('--json', required=True, type=str, help="prepared JSON") parser.add_argument('--trait', required=True, type=str, help="prepared JSON") - parser.add_argument('--header', nargs='*', action='extend', type=str, help="header fields") + parser.add_argument('--header', nargs='*', action=ExtendOverwriteDefault, type=str, help="header fields") parser.add_argument('--date_format', nargs='*', action=ExtendOverwriteDefault, default=["%Y-%m-%d"], type=str, help="if needed. default: [%%Y-%%m-%%d]") params = parser.parse_args() diff --git a/scripts/tree_to_JSON.py b/scripts/tree_to_JSON.py index 1479c1f2f..0531192ae 100644 --- a/scripts/tree_to_JSON.py +++ b/scripts/tree_to_JSON.py @@ -6,6 +6,7 @@ from collections import defaultdict sys.path.append('') from base.colorLogging import ColorizingStreamHandler +from augur.argparse_ import ExtendOverwriteDefault version = 0.1 @@ -19,9 +20,9 @@ def get_command_line_args(): beast = parser.add_argument_group('beast') beast.add_argument('--nexus', type=str, help="Path to nexus file") beast.add_argument('--most_recent_tip', type=float, help="Date of the most recent tip (in decimal format)") - beast.add_argument('--discrete_traits', type=str, nargs='+', action='extend', default=[], help="Discrete traits to extract from the BEAST annotations") - beast.add_argument('--continuous_traits', type=str, nargs='+', action='extend', default=[], help="Continuous traits to extract from the BEAST annotations") - beast.add_argument('--make_traits_log', type=str, nargs='+', action='extend', default=[], help="Convert these (continous) traits to log space: y=-ln(x)") + beast.add_argument('--discrete_traits', type=str, nargs='+', action=ExtendOverwriteDefault, default=[], help="Discrete traits to extract from the BEAST annotations") + beast.add_argument('--continuous_traits', type=str, nargs='+', action=ExtendOverwriteDefault, default=[], help="Continuous traits to extract from the BEAST annotations") + beast.add_argument('--make_traits_log', type=str, nargs='+', action=ExtendOverwriteDefault, default=[], help="Convert these (continous) traits to log space: y=-ln(x)") beast.add_argument("--fake_divergence", action="store_true", help="Set the divergence as time (prevents auspice crashing)") @@ -34,8 +35,8 @@ def get_command_line_args(): general.add_argument("--debug", action="store_const", dest="loglevel", const=logging.DEBUG, help="Enable debugging logging") general.add_argument('--output_prefix', '-o', required=True, type=str, help="Output prefix (i.e. \"_meta.json\" will be appended to this)") general.add_argument('--title', default=None, type=str, help="Title (to be displayed by auspice)") - general.add_argument("--defaults", type=str, nargs='+', action='extend', default=[], help="Auspice defaults. Format: \"key:value\"") - general.add_argument("--filters", type=str, nargs='+', action='extend', default=[], help="Auspice filters.") + general.add_argument("--defaults", type=str, nargs='+', action=ExtendOverwriteDefault, default=[], help="Auspice defaults. Format: \"key:value\"") + general.add_argument("--filters", type=str, nargs='+', action=ExtendOverwriteDefault, default=[], help="Auspice filters.") general.add_argument('--geo', type=str, help="CSV File w. header \"trait,value,latitude,longitude\". Turns on the map panel.") diff --git a/tests/test_argparse_linting.py b/tests/test_argparse_linting.py new file mode 100644 index 000000000..01db4d5ce --- /dev/null +++ b/tests/test_argparse_linting.py @@ -0,0 +1,24 @@ +# Originally based on tests/help.py from the Nextstrain CLI project.¹ +# +# ¹ +import pytest + +from augur import make_parser +from augur.argparse_ import walk_commands, ExtendOverwriteDefault + + +# Walking commands is slow, so do it only once and use it for all tests in this +# file (though currently n=1). +commands = list(walk_commands(make_parser())) + + +# Ensure we always use ExtendOverwriteDefault for options that take a variable +# number of arguments. See . +@pytest.mark.parametrize("action", [ + pytest.param(action, id = " ".join(command) + " " + "/".join(action.option_strings)) + for command, parser in commands + for action in parser._actions + if action.nargs in {"+", "*"} +]) +def test_ExtendOverwriteDefault(action): + assert isinstance(action, ExtendOverwriteDefault)