From 7e4d6aa54105dfee340a733bb6e8dd78c6d51aed Mon Sep 17 00:00:00 2001 From: Timur Gadiev Date: Fri, 2 May 2025 08:16:45 +0400 Subject: [PATCH] Add new filter plugin: 'to_prettytable' (#9954) * Add new action plugin 'prettytable' * Add integration tests for 'prettytable' plugin * Added BOTMETA details * Add COPYRIGHT details * Add 'to_prettytable' filter plugin and tests * fix: :bug: Fix add_rows method * Add changelog fragment * Remove changelog fragments * Apply code review suggestions * Correct BOTMETA and lint * refactor: :fire: Remove unnecessary code parts * fix: Fix contact details * Correct kwargs.pop and column_alignments description * Remove 'trim' filter from conditionals in tests * Add additional validations and tests * fix: Apply corrections after review * refactor: Optimize code and make some refactoring * fix: Add some minor corrections * fix: add column_alignments validation and tests * Update version_added to "10.7.0" Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> * refactor: Use TypeValidationError class for type checking * refactor: Apply suggestions * fix: documentation indent * Apply suggestion Co-authored-by: Felix Fontein * style: Adjust indentation Co-authored-by: Felix Fontein * style: Correction of examples * fix: Commit suggestion Co-authored-by: Felix Fontein * fix: Commit suggestion Co-authored-by: Felix Fontein * feat: Add correct parameters validation for empty data --------- Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> Co-authored-by: Felix Fontein --- .github/BOTMETA.yml | 2 + plugins/filter/to_prettytable.py | 414 +++++++++++ .../targets/filter_to_prettytable/aliases | 5 + .../filter_to_prettytable/tasks/main.yml | 658 ++++++++++++++++++ 4 files changed, 1079 insertions(+) create mode 100644 plugins/filter/to_prettytable.py create mode 100644 tests/integration/targets/filter_to_prettytable/aliases create mode 100644 tests/integration/targets/filter_to_prettytable/tasks/main.yml diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 2801e28ad9..4095986151 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -210,6 +210,8 @@ files: maintainers: resmo $filters/to_months.yml: maintainers: resmo + $filters/to_prettytable.py: + maintainers: tgadiev $filters/to_seconds.yml: maintainers: resmo $filters/to_time_unit.yml: diff --git a/plugins/filter/to_prettytable.py b/plugins/filter/to_prettytable.py new file mode 100644 index 0000000000..ed03ef7517 --- /dev/null +++ b/plugins/filter/to_prettytable.py @@ -0,0 +1,414 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025, Timur Gadiev +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) + +DOCUMENTATION = ''' +name: to_prettytable +short_description: Format a list of dictionaries as an ASCII table +version_added: "10.7.0" +author: Timur Gadiev (@tgadiev) +description: + - This filter takes a list of dictionaries and formats it as an ASCII table using the I(prettytable) Python library. +requirements: + - prettytable +options: + _input: + description: A list of dictionaries to format. + type: list + elements: dictionary + required: true + column_order: + description: List of column names to specify the order of columns in the table. + type: list + elements: string + header_names: + description: List of custom header names to use instead of dictionary keys. + type: list + elements: string + column_alignments: + description: + - >- + Dictionary where keys are column names and values are alignment settings. + Valid alignment values are C(left), C(center), C(right), C(l), C(c), or C(r). + - >- + For example, V({'name': 'left', 'id': 'right'}) will align the C(name) column to the left + and the C(id) column to the right. + type: dictionary +''' + +EXAMPLES = ''' +--- +- name: Set a list of users + ansible.builtin.set_fact: + users: + - name: Alice + age: 25 + role: admin + - name: Bob + age: 30 + role: user + +- name: Display a list of users as a table + ansible.builtin.debug: + msg: >- + {{ + users | community.general.to_prettytable + }} + +- name: Display a table with custom column ordering + ansible.builtin.debug: + msg: >- + {{ + users | community.general.to_prettytable( + column_order=['role', 'name', 'age'] + ) + }} + +- name: Display a table with selective column output (only show name and role fields) + ansible.builtin.debug: + msg: >- + {{ + users | community.general.to_prettytable( + column_order=['name', 'role'] + ) + }} + +- name: Display a table with custom headers + ansible.builtin.debug: + msg: >- + {{ + users | community.general.to_prettytable( + header_names=['User Name', 'User Age', 'User Role'] + ) + }} + +- name: Display a table with custom alignments + ansible.builtin.debug: + msg: >- + {{ + users | community.general.to_prettytable( + column_alignments={'name': 'center', 'age': 'right', 'role': 'left'} + ) + }} + +- name: Combine multiple options + ansible.builtin.debug: + msg: >- + {{ + users | community.general.to_prettytable( + column_order=['role', 'name', 'age'], + header_names=['Position', 'Full Name', 'Years'], + column_alignments={'name': 'center', 'age': 'right', 'role': 'left'} + ) + }} +''' + +RETURN = ''' +_value: + description: The formatted ASCII table. + type: string +''' + +try: + import prettytable + HAS_PRETTYTABLE = True +except ImportError: + HAS_PRETTYTABLE = False + +from ansible.errors import AnsibleFilterError +from ansible.module_utils._text import to_text +from ansible.module_utils.six import string_types + + +class TypeValidationError(AnsibleFilterError): + """Custom exception for type validation errors. + + Args: + obj: The object with incorrect type + expected: Description of expected type + """ + def __init__(self, obj, expected): + type_name = "string" if isinstance(obj, string_types) else type(obj).__name__ + super().__init__(f"Expected {expected}, got a {type_name}") + + +def _validate_list_param(param, param_name, ensure_strings=True): + """Validate a parameter is a list and optionally ensure all elements are strings. + + Args: + param: The parameter to validate + param_name: The name of the parameter for error messages + ensure_strings: Whether to check that all elements are strings + + Raises: + AnsibleFilterError: If validation fails + """ + # Map parameter names to their original error message format + error_messages = { + "column_order": "a list of column names", + "header_names": "a list of header names" + } + + # Use the specific error message if available, otherwise use a generic one + error_msg = error_messages.get(param_name, f"a list for {param_name}") + + if not isinstance(param, list): + raise TypeValidationError(param, error_msg) + + if ensure_strings: + for item in param: + if not isinstance(item, string_types): + # Maintain original error message format + if param_name == "column_order": + error_msg = "a string for column name" + elif param_name == "header_names": + error_msg = "a string for header name" + else: + error_msg = f"a string for {param_name} element" + raise TypeValidationError(item, error_msg) + + +def _match_key(item_dict, lookup_key): + """Find a matching key in a dictionary, handling type conversion. + + Args: + item_dict: Dictionary to search in + lookup_key: Key to look for, possibly needing type conversion + + Returns: + The matching key or None if no match found + """ + # Direct key match + if lookup_key in item_dict: + return lookup_key + + # Try boolean conversion for 'true'/'false' strings + if isinstance(lookup_key, string_types): + if lookup_key.lower() == 'true' and True in item_dict: + return True + if lookup_key.lower() == 'false' and False in item_dict: + return False + + # Try numeric conversion for string numbers + if lookup_key.isdigit() and int(lookup_key) in item_dict: + return int(lookup_key) + + # No match found + return None + + +def _build_key_maps(data): + """Build mappings between string keys and original keys. + + Args: + data: List of dictionaries with keys to map + + Returns: + Tuple of (key_map, reverse_key_map) + """ + key_map = {} + reverse_key_map = {} + + # Check if the data list is not empty + if not data: + return key_map, reverse_key_map + + first_dict = data[0] + for orig_key in first_dict.keys(): + # Store string version of the key + str_key = to_text(orig_key) + key_map[str_key] = orig_key + # Also store lowercase version for case-insensitive lookups + reverse_key_map[str_key.lower()] = orig_key + + return key_map, reverse_key_map + + +def _configure_alignments(table, field_names, column_alignments): + """Configure column alignments for the table. + + Args: + table: The PrettyTable instance to configure + field_names: List of field names to align + column_alignments: Dict of column alignments + """ + valid_alignments = {"left", "center", "right", "l", "c", "r"} + + if not isinstance(column_alignments, dict): + return + + for col_name, alignment in column_alignments.items(): + if col_name in field_names: + # We already validated alignment is a string and a valid value in the main function + # Just apply it here + alignment = alignment.lower() + table.align[col_name] = alignment[0] + + +def to_prettytable(data, *args, **kwargs): + """Convert a list of dictionaries to an ASCII table. + + Args: + data: List of dictionaries to format + *args: Optional list of column names to specify column order + **kwargs: Optional keyword arguments: + - column_order: List of column names to specify the order + - header_names: List of custom header names + - column_alignments: Dict of column alignments (left, center, right) + + Returns: + String containing the ASCII table + """ + if not HAS_PRETTYTABLE: + raise AnsibleFilterError( + 'You need to install "prettytable" Python module to use this filter' + ) + + # === Input validation === + # Validate list type + if not isinstance(data, list): + raise TypeValidationError(data, "a list of dictionaries") + + # Validate dictionary items if list is not empty + if data and not all(isinstance(item, dict) for item in data): + invalid_item = next((item for item in data if not isinstance(item, dict)), None) + raise TypeValidationError(invalid_item, "all items in the list to be dictionaries") + + # Get sample dictionary to determine fields - empty if no data + sample_dict = data[0] if data else {} + max_fields = len(sample_dict) + + # === Process column order === + # Handle both positional and keyword column_order + column_order = kwargs.pop('column_order', None) + + # Check for conflict between args and column_order + if args and column_order is not None: + raise AnsibleFilterError("Cannot use both positional arguments and the 'column_order' keyword argument") + + # Use positional args if provided + if args: + column_order = list(args) + + # Validate column_order + if column_order is not None: + _validate_list_param(column_order, "column_order") + + # Validate column_order doesn't exceed the number of fields (skip if data is empty) + if data and len(column_order) > max_fields: + raise AnsibleFilterError( + f"'column_order' has more elements ({len(column_order)}) than available fields in data ({max_fields})") + + # === Process headers === + # Determine field names and ensure they are strings + if column_order: + field_names = column_order + else: + # Use field names from first dictionary, ensuring all are strings + field_names = [to_text(k) for k in sample_dict] + + # Process custom headers + header_names = kwargs.pop('header_names', None) + if header_names is not None: + _validate_list_param(header_names, "header_names") + + # Validate header_names doesn't exceed the number of fields (skip if data is empty) + if data and len(header_names) > max_fields: + raise AnsibleFilterError( + f"'header_names' has more elements ({len(header_names)}) than available fields in data ({max_fields})") + + # Validate that column_order and header_names have the same size if both provided + if column_order is not None and len(column_order) != len(header_names): + raise AnsibleFilterError( + f"'column_order' and 'header_names' must have the same number of elements. " + f"Got {len(column_order)} columns and {len(header_names)} headers.") + + # === Process alignments === + # Get column alignments and validate + column_alignments = kwargs.pop('column_alignments', {}) + valid_alignments = {"left", "center", "right", "l", "c", "r"} + + # Validate column_alignments is a dictionary + if not isinstance(column_alignments, dict): + raise TypeValidationError(column_alignments, "a dictionary for column_alignments") + + # Validate column_alignments keys and values + for key, value in column_alignments.items(): + # Check that keys are strings + if not isinstance(key, string_types): + raise TypeValidationError(key, "a string for column_alignments key") + + # Check that values are strings + if not isinstance(value, string_types): + raise TypeValidationError(value, "a string for column_alignments value") + + # Check that values are valid alignments + if value.lower() not in valid_alignments: + raise AnsibleFilterError( + f"Invalid alignment '{value}' in 'column_alignments'. " + f"Valid alignments are: {', '.join(sorted(valid_alignments))}") + + # Validate column_alignments doesn't have more keys than fields (skip if data is empty) + if data and len(column_alignments) > max_fields: + raise AnsibleFilterError( + f"'column_alignments' has more elements ({len(column_alignments)}) than available fields in data ({max_fields})") + + # Check for unknown parameters + if kwargs: + raise AnsibleFilterError(f"Unknown parameter(s) for to_prettytable filter: {', '.join(sorted(kwargs))}") + + # === Build the table === + table = prettytable.PrettyTable() + + # Set the field names for display + display_names = header_names if header_names is not None else field_names + table.field_names = [to_text(name) for name in display_names] + + # Configure alignments after setting field_names + _configure_alignments(table, display_names, column_alignments) + + # Build key maps only if not using explicit column_order and we have data + key_map = {} + reverse_key_map = {} + if not column_order and data: # Only needed when using original dictionary keys and we have data + key_map, reverse_key_map = _build_key_maps(data) + + # If we have an empty list with no custom parameters, return a simple empty table + if not data and not column_order and not header_names and not column_alignments: + return "++\n++" + + # Process each row if we have data + for item in data: + row = [] + for col in field_names: + # Try direct mapping first + if col in key_map: + row.append(item.get(key_map[col], "")) + else: + # Try to find a matching key in the item + matched_key = _match_key(item, col) + if matched_key is not None: + row.append(item.get(matched_key, "")) + else: + # Try case-insensitive lookup as last resort + lower_col = col.lower() if isinstance(col, string_types) else str(col).lower() + if lower_col in reverse_key_map: + row.append(item.get(reverse_key_map[lower_col], "")) + else: + # No match found + row.append("") + table.add_row(row) + + return to_text(table) + + +class FilterModule(object): + """Ansible core jinja2 filters.""" + + def filters(self): + return { + 'to_prettytable': to_prettytable + } diff --git a/tests/integration/targets/filter_to_prettytable/aliases b/tests/integration/targets/filter_to_prettytable/aliases new file mode 100644 index 0000000000..afda346c4e --- /dev/null +++ b/tests/integration/targets/filter_to_prettytable/aliases @@ -0,0 +1,5 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +azp/posix/1 diff --git a/tests/integration/targets/filter_to_prettytable/tasks/main.yml b/tests/integration/targets/filter_to_prettytable/tasks/main.yml new file mode 100644 index 0000000000..69f25ad59c --- /dev/null +++ b/tests/integration/targets/filter_to_prettytable/tasks/main.yml @@ -0,0 +1,658 @@ +--- +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +# Copyright (c) 2025, Timur Gadiev (tgadiev@gmail.com) +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Install required libs + pip: + name: prettytable + state: present + delegate_to: localhost + become: false + +- name: Set test data + set_fact: + test_data: + - name: Alice + age: 25 + role: admin + - name: Bob + age: 30 + role: user + data_for_align: + - date: 2023-01-01 + description: Office supplies + amount: 123.45 + +# Test basic functionality +- name: Test basic table creation + set_fact: + basic_table: '{{ test_data | community.general.to_prettytable }}' + expected_basic_table: |- + +-------+-----+-------+ + | name | age | role | + +-------+-----+-------+ + | Alice | 25 | admin | + | Bob | 30 | user | + +-------+-----+-------+ + +- name: Verify basic table output + assert: + that: + - basic_table == expected_basic_table + +# Test column ordering +- name: Test column ordering + set_fact: + ordered_table: "{{ test_data | community.general.to_prettytable(column_order=['role', 'name', 'age']) }}" + expected_ordered_table: |- + +-------+-------+-----+ + | role | name | age | + +-------+-------+-----+ + | admin | Alice | 25 | + | user | Bob | 30 | + +-------+-------+-----+ + +- name: Verify ordered table output + assert: + that: + - ordered_table == expected_ordered_table + +# Test selective column ordering (subset of keys) +- name: Test selective column ordering + set_fact: + selective_ordered_table: "{{ test_data | community.general.to_prettytable(column_order=['name', 'role']) }}" + expected_selective_table: |- + +-------+-------+ + | name | role | + +-------+-------+ + | Alice | admin | + | Bob | user | + +-------+-------+ + +- name: Verify selective column ordering + assert: + that: + - selective_ordered_table == expected_selective_table + +# Test custom headers +- name: Test custom headers + set_fact: + headers_table: "{{ test_data | community.general.to_prettytable(header_names=['User Name', 'User Age', 'User Role']) }}" + expected_headers_table: |- + +-----------+----------+-----------+ + | User Name | User Age | User Role | + +-----------+----------+-----------+ + | Alice | 25 | admin | + | Bob | 30 | user | + +-----------+----------+-----------+ + +- name: Verify custom headers output + assert: + that: + - headers_table == expected_headers_table + +# Test selective column ordering with custom headers (subset of keys) +- name: Test selective column ordering with custom headers + set_fact: + selective_ordered_headers_table: "{{ test_data | community.general.to_prettytable(column_order=['name', 'role'], header_names=['User Name', 'User Role']) }}" + expected_selective_headers_table: |- + +-----------+-----------+ + | User Name | User Role | + +-----------+-----------+ + | Alice | admin | + | Bob | user | + +-----------+-----------+ + +- name: Verify selective column ordering with custom headers + assert: + that: + - selective_ordered_headers_table == expected_selective_headers_table + +# Test alignments +- name: Test column alignments + set_fact: + aligned_table: "{{ data_for_align | community.general.to_prettytable(column_alignments={'amount': 'right', 'description': 'left', 'date': 'center'}) }}" + expected_aligned_table: |- + +------------+-----------------+--------+ + | date | description | amount | + +------------+-----------------+--------+ + | 2023-01-01 | Office supplies | 123.45 | + +------------+-----------------+--------+ + +- name: Verify aligned table output + assert: + that: + - aligned_table == expected_aligned_table + +# Test combined options +- name: Test combined options + set_fact: + combined_table: "{{ test_data | community.general.to_prettytable( + column_order=['role', 'name', 'age'], + header_names=['Position', 'Full Name', 'Years'], + column_alignments={'role': 'left', 'name': 'center', 'age': 'right'}) }}" + expected_combined_table: |- + +----------+-----------+-------+ + | Position | Full Name | Years | + +----------+-----------+-------+ + | admin | Alice | 25 | + | user | Bob | 30 | + +----------+-----------+-------+ + +- name: Verify combined table output + assert: + that: + - combined_table == expected_combined_table + +# Test empty data +- name: Test empty data list with no parameters + set_fact: + empty_table: "{{ [] | community.general.to_prettytable }}" + expected_empty_table: |- + ++ + ++ + +- name: Verify empty table output + assert: + that: + - empty_table == expected_empty_table + +# Test empty data with column_order +- name: Test empty data list with column_order + set_fact: + empty_with_columns: "{{ [] | community.general.to_prettytable(column_order=['name', 'age', 'role']) }}" + expected_empty_with_columns: |- + +------+-----+------+ + | name | age | role | + +------+-----+------+ + +------+-----+------+ + +- name: Verify empty table with column_order + assert: + that: + - empty_with_columns == expected_empty_with_columns + +# Test empty data with header_names +- name: Test empty data list with header_names + set_fact: + empty_with_headers: "{{ [] | community.general.to_prettytable(header_names=['User Name', 'User Age', 'User Role']) }}" + expected_empty_with_headers: |- + +-----------+----------+-----------+ + | User Name | User Age | User Role | + +-----------+----------+-----------+ + +-----------+----------+-----------+ + +- name: Verify empty table with header_names + assert: + that: + - empty_with_headers == expected_empty_with_headers + +# Test empty data with combined parameters +- name: Test empty data with combined parameters + set_fact: + empty_combined: "{{ [] | community.general.to_prettytable( + column_order=['role', 'name', 'age'], + header_names=['Position', 'Full Name', 'Years'], + column_alignments={'role': 'left', 'name': 'center', 'age': 'right'}) }}" + expected_empty_combined: |- + +----------+-----------+-------+ + | Position | Full Name | Years | + +----------+-----------+-------+ + +----------+-----------+-------+ + +- name: Verify empty table with combined parameters + assert: + that: + - empty_combined == expected_empty_combined + +# Test validation with empty data +- name: Test empty data with non-list column_order (expect failure) + block: + - set_fact: + invalid_table: "{{ [] | community.general.to_prettytable(column_order=123) }}" + register: failure_result + ignore_errors: true + - name: Verify error message for empty data with invalid column_order + assert: + that: + - failure_result is failed + - > + "Expected a list of column names, got a int" in failure_result.msg + +- name: Test empty data with non-list header_names (expect failure) + block: + - set_fact: + invalid_table: "{{ [] | community.general.to_prettytable(header_names='invalid_headers') }}" + register: failure_result + ignore_errors: true + - name: Verify error message for empty data with invalid header_names + assert: + that: + - failure_result is failed + - > + "Expected a list of header names, got a string" in failure_result.msg + +- name: Test empty data with non-dictionary column_alignments (expect failure) + block: + - set_fact: + invalid_table: "{{ [] | community.general.to_prettytable(column_alignments='invalid') }}" + register: failure_result + ignore_errors: true + - name: Verify error message for empty data with invalid column_alignments + assert: + that: + - failure_result is failed + - > + "Expected a dictionary for column_alignments, got a string" in failure_result.msg + +- name: Test empty data with non-string values in column_alignments (expect failure) + block: + - set_fact: + invalid_table: "{{ [] | community.general.to_prettytable(column_alignments={'name': 123}) }}" + register: failure_result + ignore_errors: true + - name: Verify error message for empty data with non-string values in column_alignments + assert: + that: + - failure_result is failed + - > + "Expected a string for column_alignments value, got a int" in failure_result.msg + +- name: Test empty data with invalid alignment value in column_alignments (expect failure) + block: + - set_fact: + invalid_table: "{{ [] | community.general.to_prettytable(column_alignments={'name': 'invalid'}) }}" + register: failure_result + ignore_errors: true + - name: Verify error message for empty data with invalid alignment value + assert: + that: + - failure_result is failed + - > + "Invalid alignment 'invalid' in 'column_alignments'" in failure_result.msg + - > + "Valid alignments are" in failure_result.msg + +- name: Test empty data with mismatched column_order and header_names (expect failure) + block: + - set_fact: + invalid_table: "{{ [] | community.general.to_prettytable(column_order=['a', 'b', 'c'], header_names=['X', 'Y']) }}" + register: failure_result + ignore_errors: true + - name: Verify error message for empty data with mismatched lengths + assert: + that: + - failure_result is failed + - > + "'column_order' and 'header_names' must have the same number of elements" in failure_result.msg + +# Test error conditions +- name: Test non-list input (expect failure) + block: + - set_fact: + invalid_table: "{{ 'not_a_list' | community.general.to_prettytable }}" + register: failure_result + ignore_errors: true + - name: Verify error message for non-list input + assert: + that: + - failure_result is failed + - > + "Expected a list of dictionaries, got a string" in failure_result.msg + +- name: Test list with non-dictionary items (expect failure) + block: + - set_fact: + invalid_table: "{{ ['not_a_dict', 'also_not_a_dict'] | community.general.to_prettytable }}" + register: failure_result + ignore_errors: true + - name: Verify error message for non-dictionary items + assert: + that: + - failure_result is failed + - > + "Expected all items in the list to be dictionaries, got a string" in failure_result.msg + +- name: Test non-list column_order (expect failure) + block: + - set_fact: + invalid_table: "{{ test_data | community.general.to_prettytable(column_order=123) }}" + register: failure_result + ignore_errors: true + - name: Verify error message for non-list column_order + assert: + that: + - failure_result is failed + - > + "Expected a list of column names, got a int" in failure_result.msg + +- name: Test non-list header_names (expect failure) + block: + - set_fact: + invalid_table: "{{ test_data | community.general.to_prettytable(header_names='invalid_headers') }}" + register: failure_result + ignore_errors: true + - name: Verify error message for non-list header_names + assert: + that: + - failure_result is failed + - > + "Expected a list of header names, got a string" in failure_result.msg + +- name: Test unknown parameters (expect failure) + block: + - set_fact: + invalid_table: "{{ test_data | community.general.to_prettytable(unknown_param='value') }}" + register: failure_result + ignore_errors: true + - name: Verify error message for unknown parameters + assert: + that: + - failure_result is failed + - > + "Unknown parameter(s) for to_prettytable filter: unknown_param" in failure_result.msg + +- name: Test both positional args and column_order (expect failure) + block: + - set_fact: + invalid_table: "{{ test_data | community.general.to_prettytable('role', 'name', column_order=['name', 'age', 'role']) }}" + register: failure_result + ignore_errors: true + - name: Verify error message for using both positional args and column_order + assert: + that: + - failure_result is failed + - > + "Cannot use both positional arguments and the 'column_order' keyword argument" in failure_result.msg + +- name: Test non-string values in positional args (expect failure) + block: + - set_fact: + invalid_table: "{{ test_data | community.general.to_prettytable('name', 123, 'role') }}" + register: failure_result + ignore_errors: true + - name: Verify error message for non-string values in positional args + assert: + that: + - failure_result is failed + - > + "Expected a string for column name, got a int" in failure_result.msg + +- name: Test non-string values in column_order (expect failure) + block: + - set_fact: + invalid_table: "{{ test_data | community.general.to_prettytable(column_order=['name', 123, 'role']) }}" + register: failure_result + ignore_errors: true + - name: Verify error message for non-string values in column_order + assert: + that: + - failure_result is failed + - > + "Expected a string for column name, got a int" in failure_result.msg + +- name: Test non-string values in header_names (expect failure) + block: + - set_fact: + invalid_table: "{{ test_data | community.general.to_prettytable(header_names=['User Name', 456, 'User Role']) }}" + register: failure_result + ignore_errors: true + - name: Verify error message for non-string values in header_names + assert: + that: + - failure_result is failed + - > + "Expected a string for header name, got a int" in failure_result.msg + +- name: Test mismatched sizes of column_order and header_names (expect failure) + block: + - set_fact: + invalid_table: "{{ test_data | community.general.to_prettytable(column_order=['name', 'age', 'role'], header_names=['User Name', 'User Age']) }}" + register: failure_result + ignore_errors: true + - name: Verify error message for mismatched sizes + assert: + that: + - failure_result is failed + - > + "'column_order' and 'header_names' must have the same number of elements" in failure_result.msg + +- name: Test column_order with more elements than available fields (expect failure) + block: + - set_fact: + invalid_table: "{{ test_data | community.general.to_prettytable(column_order=['name', 'age', 'role', 'extra_field', 'another_extra']) }}" + register: failure_result + ignore_errors: true + - name: Verify error message for column_order with too many elements + assert: + that: + - failure_result is failed + - > + "'column_order' has more elements" in failure_result.msg + +- name: Test header_names with more elements than available fields (expect failure) + block: + - set_fact: + invalid_table: "{{ test_data | community.general.to_prettytable(header_names=['User Name', 'User Age', 'User Role', 'Extra Field', 'Another Extra']) }}" + register: failure_result + ignore_errors: true + - name: Verify error message for header_names with too many elements + assert: + that: + - failure_result is failed + - > + "'header_names' has more elements" in failure_result.msg + +- name: Test column_alignments with more elements than available fields (expect failure) + block: + - set_fact: + invalid_table: "{{ test_data | community.general.to_prettytable(column_alignments={'name': 'center', 'age': 'right', 'role': 'left', 'extra': 'center', 'another': 'left'}) }}" + register: failure_result + ignore_errors: true + - name: Verify error message for column_alignments with too many elements + assert: + that: + - failure_result is failed + - > + "'column_alignments' has more elements" in failure_result.msg + +- name: Test non-dictionary column_alignments (expect failure) + block: + - set_fact: + invalid_table: "{{ test_data | community.general.to_prettytable(column_alignments='invalid') }}" + register: failure_result + ignore_errors: true + - name: Verify error message for non-dictionary column_alignments + assert: + that: + - failure_result is failed + - > + "Expected a dictionary for column_alignments, got a string" in failure_result.msg + +- name: Test non-string keys in column_alignments (expect failure) + block: + - set_fact: + invalid_table: "{{ test_data | community.general.to_prettytable(column_alignments={123: 'center'}) }}" + register: failure_result + ignore_errors: true + - name: Verify error message for non-string keys in column_alignments + assert: + that: + - failure_result is failed + - > + "Expected a string for column_alignments key, got a int" in failure_result.msg + +- name: Test non-string values in column_alignments (expect failure) + block: + - set_fact: + invalid_table: "{{ test_data | community.general.to_prettytable(column_alignments={'name': 123}) }}" + register: failure_result + ignore_errors: true + - name: Verify error message for non-string values in column_alignments + assert: + that: + - failure_result is failed + - > + "Expected a string for column_alignments value, got a int" in failure_result.msg + +- name: Test invalid alignment value in column_alignments (expect failure) + block: + - set_fact: + invalid_table: "{{ test_data | community.general.to_prettytable(column_alignments={'name': 'invalid'}) }}" + register: failure_result + ignore_errors: true + - name: Verify error message for invalid alignment value in column_alignments + assert: + that: + - failure_result is failed + - > + "Invalid alignment 'invalid' in 'column_alignments'" in failure_result.msg + - > + "Valid alignments are" in failure_result.msg + +# Test using explicit python script to create dictionary with mixed key types +- name: Create test data with numeric keys + set_fact: + mixed_key_data: + - name: Alice + role: admin + 1: ID001 + - name: Bob + role: user + 1: ID002 + +- name: Test prettytable with mixed key types + set_fact: + mixed_key_table: "{{ mixed_key_data | community.general.to_prettytable }}" + expected_mixed_key_table: |- + +-------+-------+-------+ + | name | role | 1 | + +-------+-------+-------+ + | Alice | admin | ID001 | + | Bob | user | ID002 | + +-------+-------+-------+ + +- name: Verify mixed key types were handled correctly + assert: + that: + - mixed_key_table == expected_mixed_key_table + +# Test column ordering with numeric keys +- name: Test column ordering with numeric keys + set_fact: + mixed_ordered_table: "{{ mixed_key_data | community.general.to_prettytable(column_order=['1', 'name', 'role']) }}" + expected_ordered_numeric_table: |- + +-------+-------+-------+ + | 1 | name | role | + +-------+-------+-------+ + | ID001 | Alice | admin | + | ID002 | Bob | user | + +-------+-------+-------+ + +- name: Verify column ordering with numeric keys + assert: + that: + - mixed_ordered_table == expected_ordered_numeric_table + +# Test custom headers with numeric keys +- name: Test custom headers with numeric keys + set_fact: + mixed_headers_table: "{{ mixed_key_data | community.general.to_prettytable(header_names=['Name', 'Role', 'ID']) }}" + expected_headers_numeric_table: |- + +-------+-------+-------+ + | Name | Role | ID | + +-------+-------+-------+ + | Alice | admin | ID001 | + | Bob | user | ID002 | + +-------+-------+-------+ + +- name: Verify custom headers with numeric keys + assert: + that: + - mixed_headers_table == expected_headers_numeric_table + +# Test column alignments with numeric keys +- name: Test column alignments with numeric keys + set_fact: + mixed_aligned_table: "{{ mixed_key_data | community.general.to_prettytable(column_alignments={'1': 'right', 'name': 'left', 'role': 'center'}) }}" + expected_aligned_numeric_table: |- + +-------+-------+-------+ + | name | role | 1 | + +-------+-------+-------+ + | Alice | admin | ID001 | + | Bob | user | ID002 | + +-------+-------+-------+ + +- name: Verify column alignments with numeric keys + assert: + that: + - mixed_aligned_table == expected_aligned_numeric_table + +# Test with boolean-like string keys +- name: Create test data with boolean-like string keys + set_fact: + boolean_data: + - name: Alice + role: admin + true: 'Yes' + false: 'No' + - name: Bob + role: user + true: 'No' + false: 'Yes' + +- name: Test prettytable with boolean-like string keys + set_fact: + bool_table: "{{ boolean_data | community.general.to_prettytable }}" + expected_bool_table: |- + +-------+-------+------+-------+ + | name | role | True | False | + +-------+-------+------+-------+ + | Alice | admin | Yes | No | + | Bob | user | No | Yes | + +-------+-------+------+-------+ + +- name: Verify boolean-like keys were handled correctly + assert: + that: + - bool_table == expected_bool_table + +# Test that column_order with capitalized boolean names works via case-insensitive matching +- name: Test column ordering with capitalized boolean names + set_fact: + bool_ordered_table: "{{ boolean_data | community.general.to_prettytable(column_order=['True', 'False', 'name', 'role']) }}" + expected_bool_ordered_table: |- + +------+-------+-------+-------+ + | True | False | name | role | + +------+-------+-------+-------+ + | Yes | No | Alice | admin | + | No | Yes | Bob | user | + +------+-------+-------+-------+ + +- name: Verify that 'True' in column_order works with 'true' keys + assert: + that: + - bool_ordered_table == expected_bool_ordered_table + +# Test column alignments with boolean-like string keys +- name: Test column alignments with boolean-like string keys + set_fact: + bool_aligned_table: "{{ boolean_data | community.general.to_prettytable(column_alignments={'true': 'right', 'false': 'center', 'name': 'left'}) }}" + expected_bool_aligned_table: |- + +-------+-------+------+-------+ + | name | role | True | False | + +-------+-------+------+-------+ + | Alice | admin | Yes | No | + | Bob | user | No | Yes | + +-------+-------+------+-------+ + +- name: Verify column alignments with boolean-like string keys + assert: + that: + - bool_aligned_table == expected_bool_aligned_table