Module psdi_data_conversion.main
@file psdi_data_conversion/main.py
Created 2025-01-14 by Bryan Gillis.
Entry-point file for the command-line interface for data conversion.
Functions
def detail_converter_use(args: ConvertArgs)-
Expand source code
def detail_converter_use(args: ConvertArgs): """Prints output providing information on a specific converter, including the flags and options it allows """ converter_info = get_converter_info(args.converter) converter_class = get_supported_converter_class(args.converter) converter_name = converter_class.meta.name print_header("Converter information") print_wrap(converter_info.format_detailed(), break_long_words=False, break_on_hyphens=False) # If both an input and output format are specified, provide the degree of success for this conversion. Otherwise # list possible input/output formats from_format_info: FormatInfo | None = None to_format_info: FormatInfo | None = None if args.from_format is not None and args.to_format is not None: print_header("Conversion") qual: ConversionQualityInfo | None = None conversion_found = False try: from_format_info, to_format_info = disambiguate_formats(converter_info, args.from_format, args.to_format) qual = get_conversion_quality(converter_info, from_format_info, to_format_info) conversion_found = True except FileConverterException as e: if e.help: print_wrap(str(e), newline=True) else: raise if conversion_found and qual is None: print_wrap(f"Conversion from {from_format_info.format_word()} to {to_format_info.format_word()} with " f"{converter_info.format_word()} is not supported.", newline=True) elif conversion_found: print_wrap(f"Conversion from {from_format_info.format_word()} to {to_format_info.format_word()} with " f"{converter_info.format_word()} is possible with {qual.qual_str} conversion quality", newline=True) # If there are any potential issues with the conversion, print them out if qual.details: print_wrap(f"{tc.WARNING}WARNING:{tc.OFF} Potential data loss or extrapolation issues with this " "conversion:") for detail_line in qual.details.split("\n"): print_wrap(f"- {detail_line}") else: l_input_formats, l_output_formats = get_possible_formats(args.converter) # If one format was supplied, check if it's supported for (format_name, l_formats, to_or_from) in ((args.from_format, l_input_formats, "from"), (args.to_format, l_output_formats, "to")): if format_name is None: continue if to_or_from == "from": print_header("Input format support") else: print_header("Output format support") l_format_info = get_format_info(format_name, which="all") for format_info in l_format_info: if format_info is None: continue if format_info in l_formats: optional_not: str = "" else: optional_not: str = "not " print_wrap(f"Conversion {to_or_from} {format_info.format_inline()} is " f"{optional_not}supported by {converter_name}.") if len(l_format_info) == 1: if to_or_from == "from" and from_format_info is None: from_format_info = format_info elif to_or_from == "to" and to_format_info is None: to_format_info = format_info # List all possible formats, and which can be used for input and which for output s_all_formats: set[FormatInfo] = set(l_input_formats) s_all_formats.update(l_output_formats) l_all_formats: list[FormatInfo] = list(s_all_formats) l_all_formats.sort(key=lambda x: x.format_word().lower()) print_header(f"File formats supported by {converter_name}") max_format_length = max([displaylen(x.disambiguated_name) for x in l_all_formats]) print(" "*(max_format_length+4) + " INPUT OUTPUT DESCRIPTION") print(" "*(max_format_length+4) + " ----- ------ -----------") for file_format in l_all_formats: in_yes_or_no = "yes" if file_format in l_input_formats else "no" out_yes_or_no = "yes" if file_format in l_output_formats else "no" format_name = file_format.format_word() len_offset = len(format_name) - displaylen(format_name) print(f" {file_format.format_word():>{max_format_length+len_offset}} {in_yes_or_no:<9}" f"{out_yes_or_no:<10}{file_format.description}") print_wrap("\nFor more information on a format, including its ID (which can be used to specify it uniquely in " "case of ambiguity, and is resilient to database changes affecting the disambiguated names listed " "above), call:\n" f"{tc.CODE}{const.CL_SCRIPT_NAME} -l -f <format>{tc.OFF}") if converter_class.allowed_flags is None: print_wrap("\nInformation has not been provided about general flags accepted by this converter.", newline=True) elif len(converter_class.allowed_flags) > 0: print_header("Allowed command-line flags") for flag, d_data, _ in converter_class.allowed_flags: help = d_data.get("help", "(No information provided)") print(f" {tc.CODE}{flag}{tc.OFF}") print_wrap(help, width=const.TERM_WIDTH, initial_indent=" "*4, subsequent_indent=" "*4) if converter_class.allowed_options is None: print_wrap("\nInformation has not been provided about general options accepted by this converter.", newline=True) elif len(converter_class.allowed_options) > 0: print_header("Allowed command-line options") for option, d_data, _ in converter_class.allowed_options: help = d_data.get("help", "(No information provided)") print(f" {tc.CODE}{option} <val(s)>{tc.OFF}") print(wraptext.fill(help, initial_indent=" "*4, subsequent_indent=" "*4)) # If input/output-format specific flags or options are available for the converter but a format isn't available, # we'll want to take note of that and mention that at the end of the output mention_input_format = False mention_output_format = False from_format_ambiguous = False if args.from_format is not None: if not from_format_info: try: from_format_info = get_format_info(args.from_format) except FileConverterDatabaseException: in_flags, in_options = [], [] from_format = args.from_format from_format_ambiguous = True if from_format_info: in_flags, in_options = get_in_format_args(args.converter, from_format_info) from_format = from_format_info.disambiguated_name else: in_flags, in_options = [], [] from_format = "N/A" if converter_class.has_in_format_flags_or_options: mention_input_format = True to_format_ambiguous = False if args.to_format is not None: if not to_format_info: try: to_format_info = get_format_info(args.to_format) except FileConverterDatabaseException: out_flags, out_options = [], [] to_format = args.to_format to_format_ambiguous = True if to_format_info: out_flags, out_options = get_out_format_args(args.converter, to_format_info) to_format = to_format_info.disambiguated_name else: out_flags, out_options = [], [] to_format = "N/A" if converter_class.has_out_format_flags_or_options: mention_output_format = True # Number of character spaces allocated for flags/options when printing them out ARG_LEN = 20 for (l_args, flag_or_option, input_or_output, format_name, show_details) in ((in_flags, "flag", "input", from_format, True), (in_options, "option", "input", from_format, False), (out_flags, "flag", "output", to_format, True), (out_options, "option", "output", to_format, False)): if show_details: if input_or_output == "input" and args.from_format: detail_format(args.from_format, "in") if from_format_ambiguous: print_wrap("\nFor details on input flags and options allowed for this format, please use the " "disambiguated name or ID and call:\n" f"{tc.CODE}{const.CL_SCRIPT_NAME} -l {converter_name} -f <input_format>{tc.OFF}") elif input_or_output == "output" and args.to_format: detail_format(args.to_format, "out") if to_format_ambiguous: print_wrap("\nFor details on output flags and options allowed for this format, please use the " "disambiguated name or ID and call:\n" f"{tc.CODE}{const.CL_SCRIPT_NAME} -l {converter_name} -t <output_format>{tc.OFF}") if len(l_args) == 0: continue print_header(f"Allowed {input_or_output} {flag_or_option}s for format '{format_name}'") for arg_info in l_args: if flag_or_option == "flag": optional_brief = "" else: optional_brief = f" <{arg_info.brief}>" arg_name = f"{tc.BOLD}{arg_info.name}{optional_brief}{tc.OFF}" len_offset = len(arg_name) - displaylen(arg_name) print_wrap(f"{arg_name:>{ARG_LEN+len_offset}} {arg_info.description}", subsequent_indent=" "*(ARG_LEN+2)) if arg_info.info and arg_info.info != "N/A": print_wrap(arg_info.info, initial_indent=" "*(ARG_LEN+2), subsequent_indent=" "*(ARG_LEN+2)) print("") if input_or_output == "input": arg_type = "from" else: arg_type = "to" msg = (f"{input_or_output.capitalize()} {flag_or_option}s can be used with the {tc.CODE}`" f"--{arg_type}_{flag_or_option}s <value(s)>`{tc.OFF} command-line argument. ") if flag_or_option == "flag": msg += (f"Flags should be provided concatenated into a single word, e.g. {tc.CODE}`" f"--{arg_type}_flags xyz`{tc.OFF} will set flags {tc.CODE}x{tc.OFF}, {tc.CODE}y" f"{tc.OFF}, and {tc.CODE}z{tc.OFF}.") else: msg += (f"Options should be provided as a string of space-separated 'words'. Each 'words' " "in this string should start with the letter indicating which option is being used, followed " f"by the value for that option. E.g. {tc.CODE}`--{arg_type}_options a1 b2`{tc.OFF} will set the " f"value {tc.MESSAGE}'1'{tc.OFF} for option {tc.CODE}a{tc.OFF} and the value " f"{tc.MESSAGE}'2'{tc.OFF} for option {tc.CODE}b{tc.OFF}.") print_wrap(msg) print("") # Now at the end, bring up input/output-format-specific flags and options if mention_input_format and mention_output_format: print_wrap("For details on input/output flags and options allowed for specific formats, call:\n" f"{tc.CODE}{const.CL_SCRIPT_NAME} -l {converter_name} -f <input_format> -t <output_format>{tc.OFF}") elif mention_input_format: print_wrap("For details on input flags and options allowed for a specific format, call:\n" f"{tc.CODE}{const.CL_SCRIPT_NAME} -l {converter_name} -f <input_format> [-t <output_format>]" f"{tc.OFF}") elif mention_output_format: print_wrap("For details on output flags and options allowed for a specific format, call:\n" f"{tc.CODE}{const.CL_SCRIPT_NAME} -l {converter_name} -t <output_format> [-f <input_format>]" f"{tc.OFF}")Prints output providing information on a specific converter, including the flags and options it allows
def detail_converters_and_formats(args: ConvertArgs)-
Expand source code
def detail_converters_and_formats(args: ConvertArgs): """Prints details on available converters and formats for the user. """ if converter_is_supported(args.converter): detail_converter_use(args) if not converter_is_registered(args.converter): print_wrap(f"{tc.WARNING}WARNING:{tc.OFF} This converter is supported by this package but is not " "registered. it may be registrable by building it on your system and copying the binary to the " f"{tc.PATH}'{const.BIN_PATH_WITH_OS}'{tc.OFF} directory:", err=True) return elif args.converter != "": print_wrap(f"{tc.ERROR}ERROR:{tc.OFF} Converter {tc.MESSAGE}'{args.converter}'{tc.OFF} not recognised.", err=True) list_supported_converters(err=True) exit(1) elif args.from_format and args.to_format: detail_formats_and_possible_converters(args.from_format, args.to_format) return elif args.from_format: detail_format(args.from_format) return elif args.to_format: detail_format(args.to_format) return list_supported_converters() list_supported_formats() print("") print_wrap("For more details on a converter, call:") print(f"{tc.CODE}{const.CL_SCRIPT_NAME} -l <converter name>{tc.OFF}\n") print_wrap("For more details on a format, call:") print(f"{tc.CODE}{const.CL_SCRIPT_NAME} -l -f <format>{tc.OFF}\n") print_wrap("For a list of converters that can perform a desired conversion, call:") print(f"{tc.CODE}{const.CL_SCRIPT_NAME} -l -f <input format> -t <output format>{tc.OFF}\n") print_wrap("For a list of options provided by a converter for a desired conversion, call:") print(f"{tc.CODE}{const.CL_SCRIPT_NAME} -l <converter name> -f <input format> -t <output format>{tc.OFF}")Prints details on available converters and formats for the user.
def detail_format(format_name: str, in_or_out: str | None = None)-
Expand source code
def detail_format(format_name: str, in_or_out: str | None = None): """Prints details on a format """ l_format_info = get_format_info(format_name, which="all") if len(l_format_info) == 0: print_wrap(f"{tc.ERROR}ERROR:{tc.OFF} Format {tc.MESSAGE}'{format_name}'{tc.OFF} not recognised", err=True) list_supported_formats(err=True) exit(1) if in_or_out == "in": print_header("Input format information") elif in_or_out == "out": print_header("Output format information") else: print_header("Format information") if len(l_format_info) > 1: print_wrap(f"{tc.WARNING}WARNING:{tc.OFF} Format {tc.MESSAGE}'{format_name}'{tc.OFF} is ambiguous and could " "refer to multiple formats. It may be necessary to explicitly specify which you want to use when " f"calling this script, e.g. with {tc.CODE}`-f {format_name}-0`{tc.OFF} or using its ID - see the " "disambiguated names and IDs in the list below:", newline=True) first = True for format_info in l_format_info: # Add linebreak before each after the first if first: first = False else: print() print_wrap(format_info.format_detailed())Prints details on a format
def detail_formats_and_possible_converters(from_format: str, to_format: str)-
Expand source code
def detail_formats_and_possible_converters(from_format: str, to_format: str): """Prints details on converters that can perform a conversion from one format to another """ # Check that both formats are valid, and print an error if not either_format_failed = False try: get_format_info(from_format, which=0) except (KeyError, FileConverterDatabaseException): either_format_failed = True print_wrap(f"{tc.ERROR}ERROR:{tc.OFF} Input format {tc.MESSAGE}'{from_format}'{tc.OFF} not recognised", err=True) try: get_format_info(to_format, which=0) except (KeyError, FileConverterDatabaseException): either_format_failed = True print_wrap(f"{tc.ERROR}ERROR:{tc.OFF} Output format {tc.MESSAGE}'{from_format}'{tc.OFF} not recognised", err=True) if either_format_failed: # Let the user know about formats which are allowed list_supported_formats(err=True) exit(1) # Provide details on both the input and output formats detail_format(from_format, "in") detail_format(to_format, "out") l_possible_conversions = get_possible_conversions(from_format, to_format) # Check if no direct conversions are possible, and if formats are specified uniquely, recommend a chained conversion if len(l_possible_conversions) == 0: l_from_formats = get_format_info(from_format, which="all") l_to_formats = get_format_info(to_format, which="all") from_format_name = get_format_pretty_name(from_format) to_format_name = get_format_pretty_name(to_format) print() print_wrap(f"No direct conversions are possible from {from_format_name} to {to_format_name}") print() if len(l_from_formats) == 1 and len(l_to_formats) == 1: for only in "registered", "supported", "all": l_pathways = get_possible_conversion_pathways(l_from_formats[0], l_to_formats[0], only=only, include="shortest") if not l_pathways: continue if only == "registered" or only == "supported": converter_type_needed = only else: converter_type_needed = "unsupported" print_wrap(f"A chained conversion is possible from {from_format_name} to {to_format_name} using " f"{converter_type_needed} converters:") print_wrap(l_pathways[0].format_detailed(show_command=True)) print() # If more pathways are possible, recommend the command to list them all if len(l_pathways) > 1: print_wrap(f"To see other possible conversion pathways, call:\n" f"{tc.CODE}{const.CL_SCRIPT_NAME} --lp <best/shortest> -f {from_format} -t " f"{to_format}{tc.OFF}\n" f"({tc.MESSAGE}'best'{tc.OFF} for all equally-low-weight paths, {tc.MESSAGE}'shortest" f"'{tc.OFF} for all equally-short paths)") break else: print_wrap(f"No chained conversions are possible from {from_format} to {to_format}.") else: print_wrap("To see possible chained conversions, specify each format uniquely using the ID or " f"disambiguated name (e.g. '{tc.MESSAGE}xxx-0{tc.OFF}') listed above)") # Get a list of all different formats which share the provided name, cutting out duplicates l_from_formats = list(set([x[1] for x in l_possible_conversions])) l_from_formats.sort(key=lambda x: x.disambiguated_name) l_to_formats = list(set([x[2] for x in l_possible_conversions])) l_to_formats.sort(key=lambda x: x.disambiguated_name) # End here if no direct conversions are possible if len(l_from_formats)*len(l_to_formats) == 0: return # Print Conversion information for all combinations of formats print_header("Conversion") first_loop = True for possible_from_format, possible_to_format in product(l_from_formats, l_to_formats): # Add an extra line break before each loop after the first if first_loop: first_loop = False else: print() detail_possible_conversions(possible_from_format, possible_to_format, l_possible_conversions)Prints details on converters that can perform a conversion from one format to another
def detail_pathways(args: ConvertArgs)-
Expand source code
def detail_pathways(args: ConvertArgs): """Prints details on possible conversion pathways between two formats. """ from_format = get_format_info(args.from_format) to_format = get_format_info(args.to_format) # First, check if a direct conversion is possible l_direct_conversions = get_possible_conversions(from_format, to_format) if l_direct_conversions: print_header("Direct Conversion") detail_possible_conversions(from_format, to_format, l_direct_conversions) # If at least one direct conversion can be performed with a registered converter, we can return here if any([converter.name in L_REGISTERED_CONVERTERS for converter in [x.converter for x in l_direct_conversions]]): return print_header("Conversion Pathways") # Construct the list of conversion pathways we'll want to display for the user l_paths: list[ConversionPath] if args.listpaths == const.LP_MODE_ONE: path = get_conversion_pathway(in_format=from_format, out_format=to_format) # `path` might be None if no paths are possible. We'll use an empty list to represent that fact l_paths = [path] if path else [] elif args.listpaths == const.LP_MODE_BEST: l_paths = get_possible_conversion_pathways(in_format=from_format, out_format=to_format, include="best") else: l_paths = get_possible_conversion_pathways(in_format=from_format, out_format=to_format, include="shortest") if not l_paths: # No pathway is possible. For now, just report this. TODO: Add some logic here to try to figure out why, # and give the user a better idea of the reason print_wrap(f"No conversion pathway is possible from {from_format.format_word()} to {to_format.format_word()}.") return print_wrap("Conversion pathways are listed below, sorted from lowest to highest weight. The weight represents an " "estimate of how much data loss is expected in this path, plus some other factors to help break ties. " "The weight is represented by joined hex values in the format <property weight>-<precision weight>-" "<time weight>-<converter weight> (listed in descending order of importance). A detailed explanation " f"can be found in the file {tc.PATH}'doc/conversion_chaining.md'{tc.OFF} in the project source, but in " "brief:\n" "\n" f"{tc.BOLD}Property weight:{tc.OFF} A non-zero value here indicates a class of information is " "completely lost in the conversion, e.g. if the input format can represent connection information while " "the output cannot.\n" "\n" f"{tc.BOLD}Precision weight:{tc.OFF} Scales with how many digits of precision in numerical values are " "lost\n" "\n" f"{tc.BOLD}Time weight:{tc.OFF} Scales with how much time the conversion takes\n" "\n" f"{tc.BOLD}Converter weight:{tc.OFF} Tie-breaking factor, with converters which have been supported by " "this package given lower weights, as they're likely to work more reliably\n" "\n---\n") print_wrap(ConversionPath.format_multiple_detailed(l_paths, show_command=True)) print("")Prints details on possible conversion pathways between two formats.
def detail_possible_conversions(from_format: FormatInfo,
to_format: FormatInfo,
l_possible_conversions: list[Conversion] | None = None)-
Expand source code
def detail_possible_conversions(from_format: FormatInfo, to_format: FormatInfo, l_possible_conversions: list[Conversion] | None = None): """Prints out information on direct conversions between two formats""" if not l_possible_conversions: l_possible_conversions = get_possible_conversions(from_format, to_format) from_name = from_format.format_word() to_name = to_format.format_word() l_conversions_matching_formats = [x for x in l_possible_conversions if x.in_format == from_format and x.out_format == to_format] l_possible_registered_converters = [x.converter.format_word() for x in l_conversions_matching_formats if x.converter.name in L_REGISTERED_CONVERTERS] l_possible_unregistered_converters = [x.converter.format_word() for x in l_conversions_matching_formats if x.converter.name in L_SUPPORTED_CONVERTERS and x.converter.name not in L_REGISTERED_CONVERTERS] if len(l_possible_registered_converters)+len(l_possible_unregistered_converters) == 0: print_wrap(f"No converters are available which can perform a conversion from {from_name} to " f"{to_name}") return elif len(l_possible_registered_converters) == 0: print_wrap(f"No registered converters can perform a conversion from {from_name} to " f"{to_name}, however the following converters are supported by this package " "and can perform this conversion, but are not currently registered. They may be registrable by " "building them on your system and copying the binary to the " f"{tc.PATH}'{const.BIN_PATH_WITH_OS}'{tc.OFF} directory:", newline=True) print("\n ".join(l_possible_unregistered_converters)) return print_wrap(f"The following registered converters can convert from {from_name} to " f"{to_name}:", newline=True) print(" " + "\n ".join(l_possible_registered_converters) + "\n") if l_possible_unregistered_converters: print("") print_wrap("Additionally, the following converters are supported by this package " "and can perform this conversion, but are not currently registered. They may be registrable by " "building them on your system and copying the binary to the " f"{tc.PATH}'{const.BIN_PATH_WITH_OS}'{tc.OFF} directory:", newline=True) print(" " + "\n ".join(l_possible_unregistered_converters) + "\n") print_wrap("For details on input/output flags and options allowed by a converter for this conversion, call:") print(f"{tc.CODE}{const.CL_SCRIPT_NAME} -l <converter name> -f {strip_control_codes(from_name)} -t " f"{strip_control_codes(to_name)}{tc.OFF}")Prints out information on direct conversions between two formats
def get_argument_parser()-
Expand source code
def get_argument_parser(): """Get an argument parser for this script. Returns ------- parser : ArgumentParser An argument parser set up with the allowed command-line arguments for this script. """ # Use a custom formatter for the argument parser which adds spaces between argument descriptions for better # readability parser = ArgumentParser(formatter_class=CustomHelpFormatter) # Positional arguments parser.add_argument("l_args", type=str, nargs="*", metavar="ARG [ARG ...]", help="Normally, file(s) to be converted or zip/tar archives thereof. If an archive or archives " "are provided, the output will be packed into an archive of the same type. Filenames should be " "provided as either relative to the input directory (default current directory) or absolute. " f"If the {tc.CODE}`-l/--list`{tc.OFF} flag is set, instead the name of a " "converter can be used here to get information on it.") g_mode = parser.add_argument_group("Modes", "Arguments which invoke a different mode of " "operation for the CLI") # Arguments for alternative functionality g_mode.add_argument("-l", "--list", action="store_true", help="If provided alone, lists all available converters. Otherwise, provides information on " f"converters provided with {tc.CODE}`-w/--with`{tc.OFF} and/or input/output formats provided " f"with {tc.CODE}`-f/--from`{tc.OFF} and {tc.CODE}`-t/--to`{tc.OFF}.") g_mode.add_argument("--lp", "--lpaths", "--listpaths", type=str, nargs="?", const="one", default=None, help=f"When provided alongside {tc.CODE}`-f/--from`{tc.OFF} and {tc.CODE}`-t/--to`{tc.OFF}, " "will list direct and chained conversion pathways between the formats. The number of paths " f"listed depends on the value provided to {tc.CODE}`--lp`{tc.OFF}: {tc.MESSAGE}'one'{tc.OFF} " f"(default) will return just one of the best (lowest-weight) paths, {tc.MESSAGE}'best'{tc.OFF} " f"will return all equally-lowest-weight paths, and {tc.MESSAGE}'short'{tc.OFF} or {tc.MESSAGE}'" f"shortest'{tc.OFF} will return all equally-shortest paths. So e.g. {tc.CODE}`" f"{const.CL_SCRIPT_NAME} --lp best -f fmt1 -t fmt2`{tc.OFF} will return all conversion " f"pathways from format {tc.MESSAGE}'fmt1'{tc.OFF} to format {tc.MESSAGE}'fmt2'{tc.OFF} with " "the lowest possible weight.") g_standard = parser.add_argument_group("Standard conversion arguments", "") g_standard.add_argument("-f", "--from", type=str, default=None, help=f"The input (convert from) file extension (e.g. {tc.MESSAGE}'smi'{tc.OFF}). If not " "provided, will attempt to auto-detect format from extension of input file.") g_standard.add_argument("-i", "--in", type=str, default=None, help="The directory containing the input file(s), default current directory.") g_standard.add_argument("-t", "--to", type=str, default=None, help=f"The output (convert to) file extension (e.g. {tc.MESSAGE}'cmi'{tc.OFF}).") g_standard.add_argument("-o", "--out", type=str, default=None, help="The directory where output files should be created. If not provided, output files " f"will be created in the {tc.CODE}`-i/--in`{tc.OFF} directory if that was provided, or " "else in the directory containing the first input file.") g_standard.add_argument("-w", "--with", type=str, nargs="+", help=f"The converter to be used, or else one of the keywords {tc.MESSAGE}'auto'{tc.OFF} " f"or {tc.MESSAGE}'auto-chain'{tc.OFF} (aliases {tc.MESSAGE}'autoc'{tc.OFF} and " f"{tc.MESSAGE}'autochain'{tc.OFF} for the latter). {tc.MESSAGE}'auto'{tc.OFF} will " "automatically determine a suitable converter which can perform the conversion (this may " "require input/output formats to be unambiguously specified by using disambiguated names " f"or IDs with {tc.CODE}`-f/--from`{tc.OFF} and {tc.CODE}`-t/--to`{tc.OFF} if the " f"extensions are ambiguous). {tc.MESSAGE}'auto-chain'{tc.OFF} does the same, but will " "also determine and use a chained conversion if a single-step conversion is not possible. " f"Default {tc.MESSAGE}'auto'{tc.OFF}.") g_advanced = parser.add_argument_group("Advanced conversion arguments", "") g_advanced.add_argument("--path", type=str, nargs="+", default=None, help=f"Used instead of {tc.CODE}`-w/--with`{tc.OFF} when requesting a chained conversion " "with a specific path. This should be provided as an alternating series of converters and " "formats to specify the conversion pathway, e.g.:{tc.CODE}`-f <source_format> --path " "<converter 1> <intermediate format 1> [<converter 2> <intermediate format 2> ...] " f"<converter N> -t <target_format>`{tc.OFF} The source and target formats may " f"alternatively be specified as the beginning and end of the {tc.CODE}`--path`{tc.OFF} " f"rather than through {tc.CODE}`-f/--from`{tc.OFF} and {tc.CODE}`-t/--to`{tc.OFF}. When " f"using {tc.CODE}`--path`{tc.OFF}, all formats must be specified unambiguously, and each " f"format and converter must be specified in a single word (e.g. use {tc.MESSAGE}'OpenBabel" f"'{tc.OFF} or its ID instead of {tc.MESSAGE}'Open Babel'{tc.OFF}).") g_advanced.add_argument("--from-flags", type=str, default="", help="String of concatenated one-letter flags for how to read the input file, e.g. " f"{tc.CODE}`--from-flags xyz`{tc.OFF} will set flags {tc.CODE}x{tc.OFF}, {tc.CODE}y" f"{tc.OFF}, and {tc.CODE}z{tc.OFF}. To list the flags supported for a given input format, " f"call e.g. {tc.CODE}`{const.CL_SCRIPT_NAME} -l -f <format> -w Open Babel`{tc.OFF} at the " "command-line and look for the 'Allowed input flags' section, if one exists.") g_advanced.add_argument("--to-flags", type=str, default="", help="String of concatenated one-letter flags for how to write the output file, e.g. " f"{tc.CODE}`--from-flags xyz`{tc.OFF} will set flags {tc.CODE}x{tc.OFF}, {tc.CODE}y" f"{tc.OFF}, and {tc.CODE}z{tc.OFF}. To list the flags supported for a given output format, " f"call e.g. {tc.CODE}`{const.CL_SCRIPT_NAME} -l -t <format> -w Open Babel`{tc.OFF} at the " "command-line and look for the 'Allowed output flags' section, if one exists.") g_advanced.add_argument("--from-options", type=str, default="", help="String of space-separated options for how to read the input file. Each option " "'word' in this string should start with the letter indicating which option is being " f"used, followed by the value for that option. E.g. {tc.CODE}`--from_options a1 b2" f"`{tc.OFF} will set the value {tc.MESSAGE}'1'{tc.OFF} for option {tc.CODE}a{tc.OFF} and " f"the value {tc.MESSAGE}'2'{tc.OFF} for option {tc.CODE}b{tc.OFF}. To list the options " f"supported for a given input format, call e.g. {tc.CODE}`{const.CL_SCRIPT_NAME} -l -f " f"<format> -w Open Babel`{tc.OFF} at the command-line and look for the 'Allowed input " "options' section, if one exists.") g_advanced.add_argument("--to-options", type=str, default="", help="String of space-separated options for how to read the input output. Each option " "'word' in this string should start with the letter indicating which option is being used, " f"followed by the value for that option. E.g. {tc.CODE}`--to_options a1 b2`{tc.OFF} will " f"set the value {tc.MESSAGE}'1'{tc.OFF} for option {tc.CODE}a{tc.OFF} and the value " f"{tc.MESSAGE}'2'{tc.OFF} for option {tc.CODE}b{tc.OFF}. To list the options supported for " f"a given input format, call e.g. {tc.CODE}`{const.CL_SCRIPT_NAME} -l -t <format> -w Open " f"Babel`{tc.OFF} at the command-line and look for the 'Allowed output options' section, " "if one exists.") # Keyword arguments specific to converters for converter_name in L_REGISTERED_CONVERTERS: l_converter_args = D_CONVERTER_ARGS[converter_name] if l_converter_args: converter_info = get_converter_info(converter_name) g_converter = parser.add_argument_group(f"{converter_info.format_word()} arguments", "Arguments " f"specific to using the {converter_info.format_word()} converter") for arg_name, kwargs, _ in l_converter_args: g_converter.add_argument(arg_name, **kwargs) g_logging = parser.add_argument_group("Output and logging arguments", "") g_logging.add_argument("-q", "--quiet", action="store_true", help="If set, all terminal output aside from errors will be suppressed and no log file will " "be generated.") g_logging.add_argument("--plain", action="store_true", help="If set, will disable coloured text in output. Useful if storing the output in a file " f"or piping it to a tool such as {tc.CODE}`less`{tc.OFF}.") g_logging.add_argument("-g", "--log-file", type=str, default=None, help="The name of the file to log to. This can be provided relative to the current " f"directory (e.g. {tc.PATH}'../logs/log-file.txt'{tc.OFF}) or fully qualified (e.g. " f"{tc.PATH}'/path/to/log-file.txt'{tc.OFF}). If not provided, the log file will be named " f"after the first input file (+{tc.PATH}'.log'{tc.OFF}) and placed in the output directory " f"(specified with {tc.CODE}`-o/--out`{tc.OFF}). In {tc.MESSAGE}'full'{tc.OFF} logging mode " "(not recommended with this interface), this will apply only to logs from the outermost " "level of the script if explicitly specified. If not explicitly specified, those logs will " "be sent to stderr.") g_logging.add_argument("--log-mode", type=str, default=const.LOG_SIMPLE, help="How logs should be stored. Allowed values are: " f"{tc.MESSAGE}'full'{tc.OFF}: Multi-file logging, not recommended for the CLI, but allowed " "for a compatible interface with the public web app. " f"{tc.MESSAGE}'simple'{tc.OFF}: Logs saved to one file. " f"{tc.MESSAGE}'stdout'{tc.OFF}: Output logs and errors only to stdout. " f"{tc.MESSAGE}'none'{tc.OFF}: Output only errors to stdout.") g_logging.add_argument("--log-level", type=str, default=None, help=f"The desired level to log at. Allowed values are: {tc.MESSAGE}'DEBUG'{tc.OFF}, " f"{tc.MESSAGE}'INFO'{tc.OFF}, {tc.MESSAGE}'WARNING'{tc.OFF}, {tc.MESSAGE}'ERROR'{tc.OFF}, " f"{tc.MESSAGE}'CRITICAL'{tc.OFF}. Default: {tc.MESSAGE}'INFO'{tc.OFF} for logging to file, " f"{tc.MESSAGE}'WARNING'{tc.OFF} for logging to stdout.") g_other = parser.add_argument_group("Other arguments", "") g_other.add_argument("-n", "--no-clobber", action="store_true", help="If set, will raise an error not proceed with conversion if a file matching the output " "filename already exists.") g_other.add_argument("--delete-input", action="store_true", help="If set, input files will be deleted after conversion, default they will be kept.") g_other.add_argument("-s", "--strict", action="store_true", help="If set, will fail if one of the input files has the wrong extension (including those " "contained in archives, but not the archive files themselves). Otherwise, will only print a " "warning in this case.") g_other.add_argument("--nc", "--no-check", action="store_true", help="If set, will not perform a pre-check in the database on the validity of a conversion. " "Setting this will result in a less human-friendly error message (or may even falsely " "indicate success) if the conversion is not supported, but will save some execution time. " "Recommended only for automated execution after the user has confirmed a conversion is " "supported.") return parserGet an argument parser for this script.
Returns
parser:ArgumentParser- An argument parser set up with the allowed command-line arguments for this script.
def get_supported_converters()-
Expand source code
def get_supported_converters(): """Gets a string containing a list of supported converters """ MSG_NOT_REGISTERED = "(supported but not registered)" l_converters: list[str] = [] any_not_registered = False for converter_name in L_SUPPORTED_CONVERTERS: converter_text = get_converter_info(converter_name).format_oneline() if converter_name not in L_REGISTERED_CONVERTERS: converter_text += f" {MSG_NOT_REGISTERED}" any_not_registered = True l_converters.append(converter_text) print_header("Available converters") output_str = " " + "\n ".join(l_converters) if any_not_registered: output_str += (f"\n\nConverters marked as {tc.MESSAGE}'{MSG_NOT_REGISTERED}'{tc.OFF} are supported by this " "package, but no appropriate binary for your platform was either distributed with this package " "or found on your system") return output_strGets a string containing a list of supported converters
def handle_raised_exception(e: FileConverterException)-
Expand source code
def handle_raised_exception(e: FileConverterException): """If a recognised exception is raised, handle it appropriately, including writing out a helpful message to the user """ if not e.help: raise # If we get an exception with the help flag set, it's likely due to user error, so don't bother them with a # traceback and simply print the message to stderr if e.msg_preformatted: print(e, file=sys.stderr) else: print_wrap(f"{tc.ERROR}ERROR:{tc.OFF} {e}", err=True) exit(1)If a recognised exception is raised, handle it appropriately, including writing out a helpful message to the user
def list_supported_converters(err=False)-
Expand source code
def list_supported_converters(err=False): """Prints a list of supported converters for the user """ if err: file = sys.stderr else: file = sys.stdout print(get_supported_converters(), file=file)Prints a list of supported converters for the user
def list_supported_formats(err=False)-
Expand source code
def list_supported_formats(err=False): """Prints a list of all formats recognised by at least one registered converter """ # Make a list of all formats recognised by at least one registered converter s_all_formats: set[FormatInfo] = set() s_registered_formats: set[FormatInfo] = set() for converter_name in L_SUPPORTED_CONVERTERS: l_in_formats, l_out_formats = get_possible_formats(converter_name) # To make sure we don't see any unexpected duplicates in the set due to cached/uncached values, get the # disambiguated name of each format first [x.disambiguated_name for x in l_in_formats] [x.disambiguated_name for x in l_out_formats] s_all_formats.update(l_in_formats) s_all_formats.update(l_out_formats) if converter_name in L_REGISTERED_CONVERTERS: s_registered_formats.update(l_in_formats) s_registered_formats.update(l_out_formats) s_unregistered_formats = s_all_formats.difference(s_registered_formats) # Convert the sets to lists and alphabetise them l_registered_formats = list(s_registered_formats) l_registered_formats.sort(key=lambda x: x.disambiguated_name.lower()) l_unregistered_formats = list(s_unregistered_formats) l_unregistered_formats.sort(key=lambda x: x.disambiguated_name.lower()) # Pad the format strings to all be the same length. To keep columns aligned, all padding is done with non- # breaking spaces (\xa0), and each format is followed by a single normal space longest_format_len = max([len(x.format_word()) for x in l_registered_formats]) l_padded_formats = [f"{x.format_word():\xa0<{longest_format_len}} " for x in l_registered_formats] print_header("File formats supported by registered converters", err=err) print_wrap("".join(l_padded_formats), err=err, initial_indent=" "*4, subsequent_indent=" "*4, newline=True) if l_unregistered_formats: longest_unregistered_format_len = max([len(x) for x in l_unregistered_formats]) l_padded_unregistered_formats = [f"{x:\xa0<{longest_unregistered_format_len}} " for x in l_unregistered_formats] print_header("File formats supported by unregistered converters which are supported by this package", err=err) print_wrap("".join(l_padded_unregistered_formats), err=err, initial_indent=" ", subsequent_indent=" ", newline=True) print_wrap("Note that not all formats are supported with all converters, or both as input and as output.") if err: print("") print_wrap("For more details on a format, call:") print(f"{tc.CODE}{const.CL_SCRIPT_NAME} -l -f <format>{tc.OFF}")Prints a list of all formats recognised by at least one registered converter
def main()-
Expand source code
def main(): """Standard entry-point function for this script. """ # If no inputs were provided, print a message about usage if len(sys.argv) == 1: print_wrap(f"{tc.ERROR}ERROR:{tc.OFF} One or more argument must be supplied to specify the conversion or " f"request information about possible conversions. See the {tc.PATH}'README.md'{tc.OFF} file for " "information on using this utility and examples of basic usage, or for detailed explanation of " "arguments call:") print(f"{tc.CODE}{const.CL_SCRIPT_NAME} -h{tc.OFF}\n", file=sys.stderr) print_wrap("Some examples of basic commands:", newline=True) print_wrap("Show supported converters and formats:") print(f"{tc.CODE}{const.CL_SCRIPT_NAME} -l{tc.OFF}\n", file=sys.stderr) print_wrap("Get information on possible conversions from <fmt1> to <fmt2>:") print(f"{tc.CODE}{const.CL_SCRIPT_NAME} -l -f <fmt1> -t <fmt2>{tc.OFF}\n", file=sys.stderr) print_wrap("Convert from <fmt1> to <fmt2> with converter <conv> (or try to select a converter automatically if " "one isn't specified):") print(f"{tc.CODE}{const.CL_SCRIPT_NAME} <filename> -f <fmt1> -t <fmt2> [-w <conv>]{tc.OFF}", file=sys.stderr) exit(1) try: args = parse_args() if (args.log_mode == const.LOG_SIMPLE or args.log_mode == const.LOG_FULL) and args.log_file: # Delete any previous local log if it exists try: os.remove(args.log_file) except FileNotFoundError: pass logging.basicConfig(filename=args.log_file, level=args.log_level, format=const.LOG_FORMAT, datefmt=const.TIMESTAMP_FORMAT) else: logging.basicConfig(level=args.log_level, format=const.LOG_FORMAT) except FileConverterInputException as e: handle_raised_exception(e) logging.debug("#") logging.debug(f"# Beginning execution of script {tc.CODE}`%s`{tc.OFF}", __file__) logging.debug("#") try: run_from_args(args) except FileConverterException as e: handle_raised_exception(e) logging.debug("#") logging.debug(f"# Finished execution of script {tc.CODE}`%s`{tc.OFF}", __file__) logging.debug("#")Standard entry-point function for this script.
def parse_args()-
Expand source code
def parse_args(): """Parses arguments for this executable. Returns ------- args : Namespace The parsed arguments. """ parser = get_argument_parser() parsed_args = parser.parse_args() # Before constructing the arguments class, we check for the "plain" argument, which signifies that we should disable # coloured text in output - errors in constructing the class will use colours if not disabled, hence why we need to # disable first if desired if parsed_args.plain: tc.disable() args = ConvertArgs(parsed_args) return argsParses arguments for this executable.
Returns
args:Namespace- The parsed arguments.
def run_from_args(args: ConvertArgs)-
Expand source code
def run_from_args(args: ConvertArgs): """Workhorse function to perform primary execution of this script, using the provided parsed arguments. Parameters ---------- args : ConvertArgs The parsed arguments for this script. """ # Check if we've been asked to list options if args.list: return detail_converters_and_formats(args) # Check if we've been asked to list possible conversion pathways if args.listpaths: return detail_pathways(args) # If we listed any info, return now and don't proceed with conversion if args.list or args.listpaths: return data = {"success": "unknown", "from_flags": args.from_flags, "to_flags": args.to_flags, "from_options": args.from_options, "to_options": args.to_options, "from_arg_flags": "", "from_args": "", "to_arg_flags": "", "to_args": "", "upload_file": "true"} data.update(args.d_converter_args) success = True for filename in args.l_args: # Search for the file in the input directory qualified_filename = os.path.join(args.input_dir, filename) if not os.path.isfile(qualified_filename): # Check if we can add the format to it as an extension to find it ex_extension = f".{args.from_format}" if not qualified_filename.endswith(ex_extension): qualified_filename += ex_extension if not os.path.isfile(qualified_filename): print_wrap(f"{tc.ERROR}ERROR:{tc.OFF} Cannot find file {tc.PATH}'{filename+ex_extension}'{tc.OFF} " f"in directory {tc.PATH}'{args.input_dir}'{tc.OFF}", err=True) continue else: print_wrap(f"{tc.ERROR}ERROR:{tc.OFF} Cannot find file {tc.PATH}'{filename}'{tc.OFF} in directory " f"{tc.PATH}'{args.input_dir}'{tc.OFF}", err=True) continue if not args.quiet: if args.to_format: to_format = args.to_format elif args.path: to_format = args.path[-1][-1] else: raise FileConverterException("Unexpected path reached: no output format specified in input") print_wrap(f"Converting {tc.PATH}'{filename}'{tc.OFF} to {get_format_pretty_name(to_format)}...", newline=True) # Set up arguments for the conversion function. The dict here is arguments common to whether we run the chain # or normal function d_conversion_kwargs = {"filename": qualified_filename, "to_format": args.to_format, "from_format": args.from_format, "use_envvars": False, "input_dir": args.input_dir, "output_dir": args.output_dir, "no_check": args.no_check, "strict": args.strict, "log_file": args.log_file, "log_mode": args.log_mode, "log_level": args.log_level, "no_clobber": args.no_clobber, "delete_input": args.delete_input, "refresh_local_log": False} try: if args.chain: if args.path: d_conversion_kwargs["path"] = args.path conversion_result = run_converter_chain(**d_conversion_kwargs) else: conversion_result = run_converter(converter=args.converter, data=data, **d_conversion_kwargs) except FileConverterAbortException as e: if not e.logged: print_wrap(f"{tc.ERROR}ERROR:{tc.OFF} Attempt to convert file {filename} aborted with status code " f"{tc.MESSAGE}{e.status_code}{tc.OFF} and message:\n{tc.MESSAGE}{e}{tc.OFF}\n", err=True) e.logged = True success = False continue except FileConverterException as e: if e.help and not e.logged: print_wrap(f"{tc.ERROR}ERROR:{tc.OFF} {e}", err=True) e.logged = True elif "Conversion from" in str(e) and "is not supported" in str(e): if not e.logged: print_wrap(f"{tc.ERROR}ERROR:{tc.OFF} {e}", err=True, newline=True) detail_formats_and_possible_converters(args.from_format, args.to_format) elif e.help and not e.logged: if e.msg_preformatted: print(e, file=sys.stderr) else: print_wrap(f"{tc.ERROR}ERROR:{tc.OFF} {e}", err=True) elif not e.logged: print_wrap(f"{tc.ERROR}ERROR:{tc.OFF} Attempt to convert file {tc.PATH}'{filename}'{tc.OFF} failed at " f"converter initialization with exception type {tc.CODE}`{type(e)}`{tc.OFF} and message: " f"\n{tc.MESSAGE}{e}{tc.OFF}\n", err=True) e.logged = True success = False continue except Exception as e: if not hasattr(e, "logged") or e.logged is False: print_wrap(f"{tc.ERROR}ERROR:{tc.OFF} Attempt to convert file {tc.PATH}'{filename}'{tc.OFF} failed " f"with exception type {tc.CODE}`{type(e)}`{tc.OFF} and message: \n{tc.MESSAGE}{e}{tc.OFF}\n", err=True) e.logged = True success = False continue if not args.quiet: print_wrap(f"{tc.SUCCESS}Success!{tc.OFF} The converted file can be found at:",) print(f" {tc.PATH}{conversion_result.output_filename}{tc.OFF}") print_wrap("The log can be found at:") print(f" {tc.PATH}{conversion_result.log_filename}{tc.OFF}") if not success: exit(1)Workhorse function to perform primary execution of this script, using the provided parsed arguments.
Parameters
args:ConvertArgs- The parsed arguments for this script.
Classes
class ConvertArgs (args)-
Expand source code
class ConvertArgs: """Class storing arguments for data conversion, processed and determined from the input arguments. """ def __init__(self, args): # Start by copying over arguments. Some share names with reserved words, so we have to use `getattr` for them # Positional arguments self.l_args: list[str] = args.l_args # Keyword arguments for standard conversion self.from_format: str | None = getattr(args, "from") self._input_dir: str | None = getattr(args, "in") self.to_format: str | None = args.to self._output_dir: str | None = args.out raw_converter = getattr(args, "with") if isinstance(raw_converter, str): self.converter = regularize_name(raw_converter) elif raw_converter: joined_converter = " ".join(raw_converter) # Check if the converter is provided as an integer, and convert to int if so try: self.converter = int(joined_converter) except ValueError: self.converter = regularize_name(joined_converter) else: self.converter = None raw_path: list[str] = args.path self.delete_input = args.delete_input self.from_flags: str = args.from_flags.replace(r"\-", "-") self.to_flags: str = args.to_flags.replace(r"\-", "-") self.from_options: str = args.from_options.replace(r"\-", "-") self.to_options: str = args.to_options.replace(r"\-", "-") self.no_clobber: bool = args.no_clobber self.no_check: bool = args.nc self.strict: bool = args.strict # Keyword arguments for alternative functionality self.list: bool = args.list self.listpaths: str | None = args.lp.lower() if args.lp else None # Logging/stdout arguments self.log_mode: bool = args.log_mode self.quiet = args.quiet self._log_file: str | None = args.log_file if not args.log_level: self.log_level = None else: try: self.log_level = get_log_level_from_str(args.log_level) except ValueError as e: # A ValueError indicates an unrecognised logging level, so we reraise this with the help flag to # indicate we want to provide this as feedback to the user so they can correct their command raise FileConverterInputException(str(e), help=True) # If formats were provided as ints, convert them to the int type now try: if self.from_format: self.from_format = int(self.from_format) except ValueError: pass try: if self.to_format: self.to_format = int(self.to_format) except ValueError: pass # If formats include a leading full-stop, strip it now if isinstance(self.from_format, str) and self.from_format.startswith("."): self.from_format = self.from_format[1:] if isinstance(self.to_format, str) and self.to_format.startswith("."): self.to_format = self.to_format[1:] # Special handling for listing info # Get the converter name from the arguments if it wasn't provided by -w/--with if self.list and not self.converter: joined_converter = " ".join(self.l_args) # Check if the converter is provided as an integer, and convert to int if so try: self.converter = int(joined_converter) except ValueError: self.converter = regularize_name(joined_converter) if self.listpaths: # Check that the mode for how many pathways to return is valid if self.listpaths not in const.L_LP_MODES: raise FileConverterInputException(f"The mode provided to {tc.CODE}`--lp/--lpaths/--listpaths`{tc.OFF} " f"is invalid. Valid modes are: " + ", ".join([f"{tc.MESSAGE}'{x}'{tc.OFF}" for x in const.L_LP_MODES]), help=True) # Check that both an input and output format are provided if not (self.from_format and self.to_format): raise FileConverterInputException(f"When using {tc.CODE}`--lp/--lpaths/--listpaths`{tc.OFF} to list " f"conversion pathways, the input and output format must be uniquely " f"specified with {tc.CODE}`-f/--from`{tc.OFF} and {tc.CODE}`" f"-f/--from`{tc.OFF} respectively", help=True) if self.list or self.listpaths: # Force log mode to stdout and turn off quiet self.log_mode = const.LOG_STDOUT self.quiet = False # For these operations, any other arguments can be ignored return # Quiet mode is equivalent to logging mode == LOGGING_NONE, so normalize them if either is set if self.quiet: self.log_mode = const.LOG_NONE elif self.log_mode == const.LOG_NONE: self.quiet = True # Check validity of input if len(self.l_args) == 0: raise FileConverterInputException("One or more names of files to convert must be provided", help=True) # If a path has been provided, sort out from_format, converter, and to_format appropriately if raw_path: self.path: list[tuple[ConverterInfo, FormatInfo]] | None = self._process_path_input(raw_path) # Assuming no errors were raised here, processing the path will result in `self.from_format` being set to # the input even if it was in the path, the path being set as converter-to-format pairs, and # `self.to_format` being set to None even if it was set outside the path else: self.path = None # Ensure we can determine at least one possible format for each file if not self.from_format: l_bad_filenames: list[str] = [] for in_filename in self.l_args: ext = os.path.splitext(in_filename)[1] if not ext or len(get_format_info(ext, "all")) == 0: l_bad_filenames.append(in_filename) if l_bad_filenames: msg = "\n".join([f"Cannot determine input format for file {tc.PATH}'{x}'{tc.OFF}" for x in l_bad_filenames]) raise FileConverterInputException(msg) if self._input_dir is not None and not os.path.isdir(self._input_dir): raise FileConverterInputException(f"The provided input directory {tc.PATH}'{self._input_dir}'{tc.OFF} does " "not exist as a directory", help=True) if self.to_format is None and self.path is None: msg = wraptext.fill(f"Output format must be provided, either through {tc.CODE}`-t/--to`{tc.OFF} for " f"a direct conversion or {tc.CODE}`--path`{tc.OFF} for a chained conversion. For " "information on supported formats and converters, call:\n") msg += f"{tc.CODE}{const.CL_SCRIPT_NAME} -l{tc.OFF}" raise FileConverterInputException(msg, msg_preformatted=True, help=True) # If the output directory doesn't exist, silently create it if self._output_dir is not None and not os.path.isdir(self._output_dir): if os.path.exists(self._output_dir): raise FileConverterInputException(f"Output directory {tc.PATH}'{self._output_dir}'{tc.OFF} exists but " "is not a directory", help=True) os.makedirs(self._output_dir, exist_ok=True) # If the converter is set to be automatically determined, do so now self.auto = False if not self.converter and not self.path: self.converter = const.CONVERTER_AUTO if self.converter == const.CONVERTER_AUTO: self.auto = True self.converter = self._determine_auto_converter() if (not self.converter or self.converter == const.CONVERTER_AUTO) and not self.path: # Double check the name is set to an actual converter - this path shouldn't be possible, but catch it # explicitly here just in case, to avoid a more confusing exception later raise FileConverterInputException("Could not automatically determine converter for conversion for an " "unknown reason.") # If one of the autochain keywords is used, normalise it to the primary key if self.converter in const.L_CONVERTER_AUTOCHAIN and not self.path: self._check_from_formats_unique() self._check_to_format_unique() self.chain = True self.auto = True self.converter = const.CONVERTER_AUTOCHAIN elif self.path: self.chain = True else: self.chain = False # If not using a chain, check that the converter is recognised and registered, and get arguments specific to # this converter self.d_converter_args = {} if not self.chain: if not converter_is_supported(self.converter): msg = wraptext.fill(f"Converter {tc.MESSAGE}'{self.converter}'{tc.OFF} not recognised", width=const.TERM_WIDTH) msg += f"\n\n{get_supported_converters()}" raise FileConverterInputException(msg, help=True, msg_preformatted=True) elif not converter_is_registered(self.converter): raw_converter = get_supported_converter_class(self.converter).meta.name msg = wraptext.fill(f"Converter {tc.MESSAGE}'{raw_converter}'{tc.OFF} is not registered. It may " "be registrable by building it on your system and copying the binary to the " f"{tc.PATH}'{const.BIN_PATH_WITH_OS}'{tc.OFF} directory:", width=const.TERM_WIDTH) msg += f"\n\n{get_supported_converters()}" raise FileConverterInputException(msg, help=True, msg_preformatted=True) # Arguments specific to this converter l_converter_args = D_CONVERTER_ARGS[self.converter] if not l_converter_args: l_converter_args = [] for arg_name, _, get_data in l_converter_args: # Convert the argument name to how it will be represented in the parsed_args object while arg_name.startswith("-"): arg_name = arg_name[1:] arg_name = arg_name.replace("-", "_") self.d_converter_args.update(get_data(getattr(args, arg_name))) # If using an automatic converter or chain, check that no converter-specific arguments were provided if self.auto or self.chain: l_converter_specific_items = [] for (to_or_from, flags_or_options) in product(["to", "from"], ["flags", "options"]): arg = f"{to_or_from}_{flags_or_options}" l_converter_specific_items.append((arg, getattr(self, arg))) l_err_strs: list[str] = [] for arg, val in l_converter_specific_items: val = getattr(self, arg) if val: err_str = f"Argument {tc.MESSAGE}'{val}'{tc.OFF} was provided when using the " if self.auto: err_str += (f"{tc.MESSAGE}'{const.CONVERTER_AUTO}'{tc.OFF} or {tc.MESSAGE}'" f"{const.CONVERTER_AUTOCHAIN}'{tc.OFF} keyword for {tc.CODE}`-w/--with`{tc.OFF}. " "Converter-specific arguments cannot be provided when the converter or chain is " "automatically-determined.") else: err_str += (f"{tc.CODE}`--path`{tc.OFF} argument to specify a chained conversion pathway. " "Converter-specific arguments are not yet supported for chained conversions. If " "these are necessary, please run the conversion manually in separate steps.") l_err_strs.append(err_str) if l_err_strs: raise FileConverterInputException("\n".join(l_err_strs), help=True) # Check that the logging mode is valid if self.log_mode not in const.L_ALLOWED_LOG_MODES: raise FileConverterInputException(f"Unrecognised logging mode: {tc.MESSAGE}'{self.log_mode}'{tc.OFF}. " f"Allowed modes are: " + ", ".join([f"{tc.MESSAGE}'{x}'{tc.OFF}" for x in const.L_ALLOWED_LOG_MODES]), help=True) @property def input_dir(self): """If the input directory isn't provided, use the current directory. """ if self._input_dir is None: self._input_dir = os.getcwd() return self._input_dir @property def output_dir(self): """If the output directory isn't provided, use the input directory. """ if self._output_dir is None: self._output_dir = self.input_dir return self._output_dir @property def log_file(self): """Determine a name for the log file if one is not provided. """ if self._log_file is None: if self.list: self._log_file = const.DEFAULT_LISTING_LOG_FILE else: first_filename = os.path.join(self.input_dir, self.l_args[0]) # Find the path to this file if not os.path.isfile(first_filename): msg = f"Input file {tc.PATH}'{first_filename}'{tc.OFF} cannot be found" if self.from_format: test_filename = first_filename + f".{self.from_format}" if os.path.isfile(test_filename): first_filename = test_filename else: if not first_filename.endswith(f".{self.from_format}"): msg += f" either at the provided location or at {tc.PATH}'{test_filename}'{tc.OFF}." raise FileConverterInputException(msg, help=True) else: raise FileConverterInputException(msg, help=True) filename_base = os.path.split(split_archive_ext(first_filename)[0])[1] if self.log_mode == const.LOG_FULL: # For server-style logging, other files will be created and used for logs self._log_file = None else: self._log_file = os.path.join(self.output_dir, filename_base + const.LOG_EXT) return self._log_file @staticmethod def _check_path_format_unambiguous(file_format: str | int | FormatInfo, allow_not_found: bool = False, raise_immediately=False): """Check that a format provided as part of `--path` is unambiguous, and record an appropriate message (and optionally raise an exception) if not""" # In this context, the format might be provided as an int represented as a string, so try to convert to int if # possible try: file_format = int(file_format) except ValueError: pass l_format_info = get_format_info(file_format, "all") msg = "" format_info: FormatInfo | None = None if not l_format_info: if allow_not_found: return None, "" msg = (f"{tc.MESSAGE}'{file_format}'{tc.OFF} is not recognised as a valid format in {tc.CODE}`--path" f"`{tc.OFF}") elif len(l_format_info) > 1: msg = (f"{tc.MESSAGE}'{file_format}'{tc.OFF} is ambiguous and can correspond to multiple possible " f"formats. When using the {tc.CODE}`--path`{tc.OFF} argument, all formats must be uniquely " "specified. Please use the disambiguated name or ID for the desired format from the following " "list:\n" + "\n".join([x.format_oneline() for x in l_format_info])) else: format_info = l_format_info[0] if msg and raise_immediately: raise FileConverterInputException(msg, help=True) return format_info, msg @staticmethod def _check_path_converter_valid(converter: str | int | ConverterInfo): """Check that a converter provided as part of `--path` is valid and registered, and record an appropriate message if not""" # In this context, the converter might be provided as an int represented as a string, so try to convert to int # if possible try: converter = int(converter) except ValueError: pass try: converter_info = get_converter_info(converter) except FileConverterDatabaseException: msg = (f"{tc.ERROR}ERROR:{tc.OFF} {tc.MESSAGE}'{converter}'{tc.OFF} is not recognised as a valid " f"converter in {tc.CODE}`--path`{tc.OFF}. Check that you've entered all converter names in " f"{tc.CODE}`--path`{tc.OFF} without spaces (e.g. use {tc.MESSAGE}'OpenBabel'{tc.OFF} instead of " f"{tc.MESSAGE}'Open Babel'{tc.OFF}), and that you alternated converters and formats in " f"{tc.CODE}`--path`{tc.OFF}") return None, msg if not converter_is_supported(converter_info): msg = (f"{tc.ERROR}ERROR:{tc.OFF} {tc.MESSAGE}{converter_info.format_word()}{tc.OFF} is not " f"supported by this package. To see a list of supported converters, call:" f"{tc.CODE}{const.CL_SCRIPT_NAME} -l{tc.OFF}") return converter_info, msg if not converter_is_registered(converter_info): msg = (f"{tc.ERROR}ERROR:{tc.OFF} {tc.MESSAGE}{converter_info.format_word()}{tc.OFF} is not " f"registered. It may be possible to register it by building it on your system and copying its " f"binary to the {tc.PATH}'{const.BIN_PATH_WITH_OS}'{tc.OFF} folder in this project.") return converter_info, msg return converter_info, "" def _process_path_input(self, raw_path: list[str]): """Process he input path, `from_format`, `to_format`, and `converter` to check for any issues and sort it all into a standard format where: - `self.from_format` is the `FormatInfo` for the input format - `self.to_format` is None - `self.converter` is None - `self.path` is a list of `ConverterInfo`, `FormatInfo` tuples """ # Copy of `raw_path`, which we can edit as we go in this method working_path = raw_path[:] # First, check the raw path to see if it starts with a format, so we can set that to `self.from_format` (or if # the latter is already set, make sure it matches) and remove it from the path first_path_format_info, first_path_msg = self._check_path_format_unambiguous(raw_path[0], allow_not_found=True) if first_path_format_info: # It looks like the first element of the path is a format. Check that if `self.from_format` is also # provided, it matches if self.from_format: from_format_info, _ = self._check_path_format_unambiguous(self.from_format, raise_immediately=True) if first_path_format_info != from_format_info: msg = (f"The format {tc.MESSAGE}'{self.from_format}'{tc.OFF} provided to {tc.CODE}`-f/--from" f"`{tc.OFF} does not match {tc.MESSAGE}'{raw_path[0]}'{tc.OFF}, the first format provided " f"to {tc.CODE}`--path`{tc.OFF}. When using {tc.CODE}`--path`{tc.OFF}, the input format " f"should be provided either to {tc.CODE}`-f/--from`{tc.OFF} or {tc.CODE}`--path`{tc.OFF}, " "or the two should match") raise FileConverterInputException(msg, help=True) self.from_format = from_format_info else: self.from_format = first_path_format_info # Reassign `working_path` to not include the `from_format`, so it should be in a consistent format now working_path = raw_path[1:] # Next, check if the output format is included in the working path or not last_path_format_info, _ = self._check_path_format_unambiguous(raw_path[-1], allow_not_found=True) if last_path_format_info: # It looks like the last element of the path is a format. Check that if `self.to_format` is also # provided, it matches if self.to_format: to_format_info, _ = self._check_path_format_unambiguous(self.to_format, raise_immediately=True) if last_path_format_info != to_format_info: msg = (f"The format {tc.MESSAGE}'{self.to_format}'{tc.OFF} provided to {tc.CODE}`-t/--to" f"`{tc.OFF} does not match {tc.MESSAGE}'{raw_path[-1]}'{tc.OFF}, the last format provided " f"to {tc.CODE}`--path`{tc.OFF}. When using {tc.CODE}`--path`{tc.OFF}, the output format " f"should be provided either to {tc.CODE}`-t/--to`{tc.OFF} or {tc.CODE}`--path`{tc.OFF}, or " "the two should match") raise FileConverterInputException(msg, help=True) self.to_format = None elif self.to_format: working_path.append(self.to_format) self.to_format = None else: msg = (f"No output format was provided. When using {tc.CODE}`--path`{tc.OFF}, the output " f"format should be provided either to {tc.CODE}`-t/--to`{tc.OFF} or {tc.CODE}`--path`{tc.OFF}, or " "the two should match") raise FileConverterInputException(msg, help=True) # At this point, if the path was provided validly, it should be of even length if not len(working_path) % 2 == 0: msg = (f"The provided {tc.CODE}`--path`{tc.OFF} is invalid due to an incorrect number of " "elements. Check that it alternates between converters and formats, and that converter names do " f"not include spaces (e.g. use {tc.MESSAGE}'OpenBabel'{tc.OFF} instead of {tc.MESSAGE}'" f"Open Babel'{tc.OFF})") raise FileConverterInputException(msg, help=True) # Now start constructing the path, checking converters and formats are valid as we go path: list[tuple[ConverterInfo, FormatInfo]] = [] l_msgs: list[str] = [] for converter, file_format in batched(working_path, n=2): converter_info, converter_msg = self._check_path_converter_valid(converter) format_info, format_msg = self._check_path_format_unambiguous(file_format) if not converter_msg and not format_msg: path.append((converter_info, format_info)) else: if converter_msg: l_msgs.append(converter_msg) if format_msg: l_msgs.append(format_msg) if l_msgs: msg = "\n".join(l_msgs) raise FileConverterInputException(msg, help=True) return path def _get_possible_converters(self, from_format_info: FormatInfo): """Get a list of all converters which can perform a conversion from `from_format` to `self.to_format`""" l_possible_conversions = get_possible_conversions(from_format_info, self.to_format) s_possible_converters = {x[0] for x in l_possible_conversions} return s_possible_converters def _get_best_converter(self, s_converters: set[ConverterInfo], s_from_formats: set[FormatInfo]) -> ConverterInfo: """Determine the best converter from which has the lowest total weight across all formats""" best_weight: int = CONV_WEIGHT_MAX best_converter = None for converter_info in s_converters: weight = sum([get_conversion_weight(converter_info, x, self.to_format) for x in s_from_formats]) if weight < best_weight: best_converter = converter_info best_weight = weight return best_converter.name def _check_to_format_unique(self, listpaths_mode=False): """Check that the output format is uniquely specified""" l_to_formats: list[FormatInfo] = get_format_info(self.to_format, "all") if not l_to_formats: raise FileConverterInputException(f"{tc.MESSAGE}'{self.to_format}'{tc.OFF} is not recognised as a valid " "output format. To see supported formats, call:\n" f"{tc.CODE}{const.CL_SCRIPT_NAME} -l{tc.OFF}", help=True) elif len(l_to_formats) > 1: msg = (f"{tc.MESSAGE}'{self.to_format}'{tc.OFF} is ambiguous and can correspond " f"to multiple possible output formats. ") if listpaths_mode: msg += (f"When using {tc.CODE}'--lp/--lpaths/--listpaths'{tc.OFF}, ") else: msg += (f"When using the {tc.MESSAGE}'{const.CONVERTER_AUTO}'{tc.OFF} or {tc.MESSAGE}'" f"{const.CONVERTER_AUTOCHAIN}'{tc.OFF} keyword for {tc.CODE}`-w/--with`{tc.OFF}, ") msg += ("both the input and output formats must be uniquely specified. Please use the disambiguated name " "or ID for the desired format from the following list:\n" + "\n".join([x.format_oneline() for x in l_to_formats])) raise FileConverterInputException(msg, help=True) def _check_from_formats_unique(self, listpaths_mode=False): """Check that the input formats are uniquely specified""" if not listpaths_mode and not self.from_format: s_input_exts = {os.path.splitext(x)[1] for x in self.l_args} if len(s_input_exts) == 1: self.from_format = s_input_exts.pop()[1:] else: # Check if there's exactly one possible format for each extension s_format_infos = set() l_bad_exts: list[str] = [] for ext in s_input_exts: l_format_infos = get_format_info(ext, "all") if len(l_format_infos) != 1: l_bad_exts.append(ext) else: s_format_infos.add(l_format_infos[0]) if l_bad_exts: msg = (f"When using the {tc.MESSAGE}'{const.CONVERTER_AUTO}'{tc.OFF} or {tc.MESSAGE}'" f"{const.CONVERTER_AUTOCHAIN}'{tc.OFF} keyword for {tc.CODE}`-w/--with`{tc.OFF}, either " f"the {tc.CODE}`-f/--from`{tc.OFF} argument must be provided to specify input format, or " "else input format must be uniquely identifiable for all input files. The following files " "could not have their format uniquely identified: " + ", ".join([f"{tc.PATH}'{x}'{tc.OFF}" for x in self.l_args if os.path.splitext(x)[1] in l_bad_exts])) raise FileConverterInputException(msg, help=True) else: return s_format_infos l_from_formats: list[FormatInfo] = get_format_info(self.from_format, "all") if len(l_from_formats) != 1: msg = (f"{tc.MESSAGE}'{self.from_format}'{tc.OFF} is ambiguous and can correspond " f"to multiple possible output formats. ") if listpaths_mode: msg += (f"When using {tc.CODE}'--lp/--lpaths/--listpaths'{tc.OFF}, the input format specified with " f"{tc.CODE}`-f/--from`{tc.OFF} ") else: msg += (f"When using the {tc.MESSAGE}'{const.CONVERTER_AUTO}'{tc.OFF} or {tc.MESSAGE}'" f"{const.CONVERTER_AUTOCHAIN}'{tc.OFF} keyword for {tc.CODE}`-w/--with`{tc.OFF}, the input " f"format determined from the extension of the input file or specified with {tc.CODE}`-f/--from" f"`{tc.OFF} ") msg += ("must unambiguously identify a format. Please use the disambiguated name or ID for the desired " "format from the following list:\n" + "\n".join([x.format_oneline() for x in l_from_formats])) raise FileConverterInputException(msg, help=True) return {l_from_formats[0]} def _determine_auto_converter(self): """Automatically determine the converter to use when the 'auto' keyword is used""" self._check_to_format_unique() s_from_formats = self._check_from_formats_unique() ls_converters = [self._get_possible_converters(x) for x in s_from_formats] s_converters = reduce(lambda s1, s2: s1.intersection(s2), ls_converters) if len(s_converters) == 0: if len(s_from_formats) > 1: raise FileConverterInputException("No converter is available which can perform a conversion of all " f"input files to {tc.MESSAGE}'{self.to_format}'{tc.OFF}. Please " "try converting files in batches of one type at a time", help=True) else: raise FileConverterInputException("No converter is available which can perform a conversion of " f"{tc.MESSAGE}'{self.from_format}'{tc.OFF} to {tc.MESSAGE}'" f"{self.to_format}'{tc.OFF}, so automatic conversion is not " "possible. Please try an automatic chained conversion with " f"{tc.CODE}`-w autochain`{tc.OFF} or search for chained conversions " f"with {tc.CODE}`--lp best -f {self.from_format} -t {self.to_format}" f"`{tc.OFF}", help=True) return self._get_best_converter(s_converters, s_from_formats)Class storing arguments for data conversion, processed and determined from the input arguments.
Instance variables
prop input_dir-
Expand source code
@property def input_dir(self): """If the input directory isn't provided, use the current directory. """ if self._input_dir is None: self._input_dir = os.getcwd() return self._input_dirIf the input directory isn't provided, use the current directory.
prop log_file-
Expand source code
@property def log_file(self): """Determine a name for the log file if one is not provided. """ if self._log_file is None: if self.list: self._log_file = const.DEFAULT_LISTING_LOG_FILE else: first_filename = os.path.join(self.input_dir, self.l_args[0]) # Find the path to this file if not os.path.isfile(first_filename): msg = f"Input file {tc.PATH}'{first_filename}'{tc.OFF} cannot be found" if self.from_format: test_filename = first_filename + f".{self.from_format}" if os.path.isfile(test_filename): first_filename = test_filename else: if not first_filename.endswith(f".{self.from_format}"): msg += f" either at the provided location or at {tc.PATH}'{test_filename}'{tc.OFF}." raise FileConverterInputException(msg, help=True) else: raise FileConverterInputException(msg, help=True) filename_base = os.path.split(split_archive_ext(first_filename)[0])[1] if self.log_mode == const.LOG_FULL: # For server-style logging, other files will be created and used for logs self._log_file = None else: self._log_file = os.path.join(self.output_dir, filename_base + const.LOG_EXT) return self._log_fileDetermine a name for the log file if one is not provided.
prop output_dir-
Expand source code
@property def output_dir(self): """If the output directory isn't provided, use the input directory. """ if self._output_dir is None: self._output_dir = self.input_dir return self._output_dirIf the output directory isn't provided, use the input directory.