diff --git a/CHANGELOG.md b/CHANGELOG.md index e512a46d..a1e70cf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.33.3] - 2024-11-26 + +### Added +- Added the ability to sort reports by multiple fields + ## [3.33.2] - 2024-11-26 ### Fixed diff --git a/docs/man/_sat-format-opts.rst b/docs/man/_sat-format-opts.rst index 4cd4f1a2..86d6d0cb 100644 --- a/docs/man/_sat-format-opts.rst +++ b/docs/man/_sat-format-opts.rst @@ -15,7 +15,13 @@ These options govern the format of the output. Reverses the sorting order. **--sort-by HEADING** - Sort by the selected heading. Can also accept a 0-based column index. + Sort by the selected heading or comma-separated list of headings. + Can also accept a 0-based column index or comma-separated list + of 0-based column indexes. + E.g. "--sort-by product_name,product_version" will sort + results by product name and then by product version. Can accept a column + name or a 0-based index. Enclose the column name in + double quotes if it contains a space. **--show-empty** Show values for columns even if every value is ``EMPTY``. By default, diff --git a/sat/cli/showrev/main.py b/sat/cli/showrev/main.py index 8248dc15..6ea52135 100644 --- a/sat/cli/showrev/main.py +++ b/sat/cli/showrev/main.py @@ -64,8 +64,13 @@ def do_showrev(args): """ reports = [] + # The `showrev` command sets the default of `--sort-by` to None, so we can use that to + # determine if the user explicitly set the value, and use a special default if not. + if args.sort_by is None: + sort_by = ['product_name', 'product_version'] + else: + sort_by = args.sort_by # report formatting - sort_by = args.sort_by reverse = args.reverse no_headings = get_config_value('format.no_headings') no_borders = get_config_value('format.no_borders') diff --git a/sat/cli/showrev/parser.py b/sat/cli/showrev/parser.py index 4e55dd20..11b53155 100644 --- a/sat/cli/showrev/parser.py +++ b/sat/cli/showrev/parser.py @@ -1,7 +1,7 @@ # # MIT License # -# (C) Copyright 2019-2020 Hewlett Packard Enterprise Development LP +# (C) Copyright 2019-2020, 2024 Hewlett Packard Enterprise Development LP # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the "Software"), @@ -39,7 +39,7 @@ def add_showrev_subparser(subparsers): None """ - format_options = sat.parsergroups.create_format_options() + format_options = sat.parsergroups.create_format_options(sort_by_default=None) filter_options = sat.parsergroups.create_filter_options() showrev_parser = subparsers.add_parser( diff --git a/sat/parsergroups.py b/sat/parsergroups.py index 7f1dbac9..f044d7d5 100644 --- a/sat/parsergroups.py +++ b/sat/parsergroups.py @@ -1,7 +1,7 @@ # # MIT License # -# (C) Copyright 2019-2022 Hewlett Packard Enterprise Development LP +# (C) Copyright 2019-2022, 2024 Hewlett Packard Enterprise Development LP # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the "Software"), @@ -36,7 +36,16 @@ LOGGER = logging.getLogger(__name__) -def create_format_options(): +def create_format_options(sort_by_default='0'): + """Creates a parser containing options for formatting. + + sort_by_default: allows for the value of --sort_by to be set + when calling the method. It is set to 0 or the + first column of data being sorted. + + Returns: an ArgumentParser object configured with options and help + text for formatting. + """ parser = ArgumentParser(add_help=False) group = parser.add_argument_group( @@ -64,10 +73,15 @@ def create_format_options(): default=False, action='store_true') group.add_argument( - '--sort-by', metavar='FIELD', default=0, - help=('Select which column to sort by. Can accept a column name ' - 'or a 0-based index. Enclose the column name in double quotes ' - 'if it contains a space.')) + '--sort-by', default=sort_by_default, + type=lambda v: v.split(','), + help=('Sort by the selected heading or comma-separated list of ' + 'headings. Can also accept a 0-based column index or ' + 'comma-separated list of 0-based column indexes.' + 'E.g. "--sort-by product_name,product_version" will sort ' + 'results by product name and then by product version. Can accept a column ' + 'name or a 0-based index. Enclose the column name in ' + 'double quotes if it contains a space.')) group.add_argument( '--show-empty', diff --git a/sat/report.py b/sat/report.py index 63220573..5358ec9a 100644 --- a/sat/report.py +++ b/sat/report.py @@ -1,7 +1,7 @@ # # MIT License # -# (C) Copyright 2019-2023 Hewlett Packard Enterprise Development LP +# (C) Copyright 2019-2024 Hewlett Packard Enterprise Development LP # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the "Software"), @@ -93,9 +93,8 @@ def __init__(self, headings, title=None, Args: headings: Headings for the table's columns. title: Title for the table - sort_by: Sort the output by the desired column when printing - in tabular format. Can be the name of a heading, or a 0-based - index. + sort_by: List of columns to sort by when printing in tabular format. + Each column can be the name of a heading, or a 0-based index. reverse: If True, then reverse the sorting order. no_headings: If True, then omit the title block and column headings from the display. @@ -157,21 +156,30 @@ def __init__(self, headings, title=None, LOGGER.warning("See the man page for this subcommand for further details on filter syntax.") sys.exit(1) - # find the heading to sort on - if sort_by is not None: - warn_str = "Element '%s' is not in %s. Output will be unsorted." - try: - self.sort_by = int(self.sort_by) - self.sort_by = self.headings[self.sort_by] - except IndexError: - # sort_by is out of range. - LOGGER.warning(warn_str, sort_by, self.headings) + # find the heading(s) to sort on + if self.sort_by is not None: + if not isinstance(self.sort_by, list): + self.sort_by = [self.sort_by] + warn_str = "Element '%s' is not in %s. Output will be unsorted on that element." + valid_sort_by = [] + for i in range(len(self.sort_by)): + try: + index = int(self.sort_by[i]) + valid_sort_by.append(self.headings[index]) + except IndexError: + # sort_by is out of range. + LOGGER.warning(warn_str, self.sort_by[i], self.headings) + except ValueError: + # sort_by is not an int. + if match_query_key(sort_by[i], self.headings): + valid_sort_by.append(match_query_key(self.sort_by[i], headings)) + else: + LOGGER.warning(warn_str, self.sort_by[i], self.headings) + + self.sort_by = valid_sort_by + + if self.sort_by == []: self.sort_by = None - except ValueError: - # sort_by is not an int. - self.sort_by = match_query_key(self.sort_by, headings) - if not self.sort_by: - LOGGER.warning(warn_str, sort_by, self.headings) if display_headings is not None: self.display_headings = [] @@ -290,12 +298,13 @@ def sort_data(self): If `self.sort_by` is None, no sorting is done. """ if self.sort_by is not None: - try: - self.data.sort(key=lambda d: d[self.sort_by], reverse=self.reverse) - except TypeError: - LOGGER.info("Converting all values of '%s' field to str " - "to allow sorting.", self.sort_by) - self.data.sort(key=lambda d: str(d[self.sort_by]), reverse=self.reverse) + for element in reversed(self.sort_by): + try: + self.data.sort(key=lambda d: d[element], reverse=self.reverse) + except TypeError: + LOGGER.info("Converting all values of '%s' field to str " + "to allow sorting.", self.sort_by) + self.data.sort(key=lambda d: str(d[element]), reverse=self.reverse) def remove_empty_and_missing(self, data_rows): """Removes columns which have only EMPTY_VALUE or MISSING_VALUE. diff --git a/tests/test_report.py b/tests/test_report.py index 6ef1638f..226720e3 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -1,7 +1,7 @@ # # MIT License # -# (C) Copyright 2019-2023 Hewlett Packard Enterprise Development LP +# (C) Copyright 2019-2024 Hewlett Packard Enterprise Development LP # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the "Software"), @@ -390,6 +390,33 @@ def test_sorted_print(self): for expected, actual in zip(self.entries, pt_s): self.assertEqual(expected, actual) + def test_sorted_list_print(self): + """The internal PT should be sorted on the first column and then the second column + """ + report = Report(self.headings, sort_by=[0, 1]) + + report.add_rows(self.out_of_order) + pt_s = get_report_printed_list(report) + + for expected, actual in zip(self.entries, pt_s): + self.assertEqual(expected, actual) + + def test_sorted_list_with_matches_print(self): + """Test sort on first and second column when elements are equal in first column + """ + e1 = ['alice', 'mars', 'red'] + e2 = ['bob', 'venus', 'blue'] + e3 = ['charlie', 'earth', 'purple'] + e4 = ['alice', 'earth', 'green'] + out_of_order = [e1, e3, e2, e4] + expected_order = [e4, e1, e2, e3] + report = Report(self.headings, sort_by=[0, 1]) + report.add_rows(out_of_order) + + pt_s = get_report_printed_list(report) + + self.assertEqual(expected_order, pt_s) + def test_sorted_yaml(self): """The YAML output list should be sorted as well. """ @@ -467,6 +494,19 @@ def test_sort_heading_invalid(self): for expected, actual in zip(self.out_of_order, pt_s): self.assertEqual(expected, actual) + def test_sort_heading_invalid_multiple(self): + """The PT should default to not sorting if the heading is not present. + """ + report = Report(self.headings, sort_by=['does-not-exist', 'does-not-exist-two']) + + self.assertEqual(None, report.sort_by) + + report.add_rows(self.out_of_order) + pt_s = get_report_printed_list(report) + + for expected, actual in zip(self.out_of_order, pt_s): + self.assertEqual(expected, actual) + def test_sort_heading_idx_invalid(self): """The PT should not sort if the idx is out-of-range. """ @@ -485,7 +525,7 @@ def test_sort_heading_abbreviation(self): """ report = Report(self.headings, sort_by='pl') - self.assertEqual('place', report.sort_by) + self.assertEqual(['place'], report.sort_by) report.add_rows(self.out_of_order) pt_s = get_report_printed_list(report) @@ -497,7 +537,7 @@ def test_sort_unique_subsequence(self): """The report should sort on a unique subsequence. """ report = Report(self.headings, sort_by='clr') - self.assertEqual('color', report.sort_by) + self.assertEqual(['color'], report.sort_by) report.add_rows(self.out_of_order) pt_s = get_report_printed_list(report) @@ -509,7 +549,7 @@ def test_sort_ambiguous_subsequence(self): """The report should sort on the first match. """ report = Report(self.headings, sort_by='ae') - self.assertEqual('name', report.sort_by) + self.assertEqual(['name'], report.sort_by) report.add_rows(self.out_of_order) pt_s = get_report_printed_list(report)