From e4874f7a76b27e00c0713ac7c421ef110e2ae3c6 Mon Sep 17 00:00:00 2001 From: Timur Gadiev <Timur_Gadiev@epam.com> Date: Tue, 1 Apr 2025 17:27:41 +0400 Subject: [PATCH 1/8] Add new action plugin 'prettytable' --- plugins/action/prettytable.py | 145 +++++++++++++++++++++++++++++++++ plugins/modules/prettytable.py | 145 +++++++++++++++++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 plugins/action/prettytable.py create mode 100644 plugins/modules/prettytable.py diff --git a/plugins/action/prettytable.py b/plugins/action/prettytable.py new file mode 100644 index 0000000000..cb210e30bc --- /dev/null +++ b/plugins/action/prettytable.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, quidame <quidame@poivron.org> +# 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 annotations + +from typing import Any, Dict, List, Optional + +__metaclass__ = type # pylint: disable=C0103 + +from ansible.errors import AnsibleError, AnsibleUndefinedVariable +from ansible.module_utils._text import to_text +from ansible.plugins.action import ActionBase + +try: + import prettytable + + HAS_PRETTYTABLE = True +except ImportError: + HAS_PRETTYTABLE = False + + +class ActionModule(ActionBase): + """Print prettytable from list of dicts.""" + + TRANSFERS_FILES = False + _VALID_ARGS = frozenset( + ("data", "column_order", "header_names", "column_alignments") + ) + _VALID_ALIGNMENTS = {"left", "center", "right", "l", "c", "r"} + + def run( + self, tmp: Optional[str] = None, task_vars: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + if task_vars is None: + task_vars = dict() + + result = super(ActionModule, self).run(tmp, task_vars) # pylint: disable=R1725 + del tmp # tmp no longer has any effect + + try: + self._handle_options() + + if not HAS_PRETTYTABLE: + raise AnsibleError( + 'You need to install "prettytable" python module before running this module' + ) + + data = self._task.args.get("data") + if data is None: # Only check for None, allow empty list + raise AnsibleError("The 'data' parameter is required") + + if not isinstance(data, list): + raise AnsibleError( + "Expected a list of dictionaries, got a string" + if isinstance(data, (str, bytes)) + else f"Expected a list of dictionaries, got {type(data).__name__}" + ) + + 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)) + raise AnsibleError( + "All items in the list must be dictionaries, got a string" + if isinstance(invalid_item, (str, bytes)) + else f"All items in the list must be dictionaries, got {type(invalid_item).__name__}" + ) + + # Special handling for empty data + if not data: + result["pretty_table"] = "++\n++" + else: + table = self._create_table(data) + result["pretty_table"] = to_text(table) + + result["_ansible_verbose_always"] = True + result["failed"] = False + + except (AnsibleError, AnsibleUndefinedVariable) as e: + result["failed"] = True + result["msg"] = str(e) + + return result + + def _handle_options(self) -> None: + """Validate module arguments.""" + argument_spec = { + "data": {"type": "list", "required": True}, + "column_order": {"type": "list", "elements": "str"}, + "header_names": {"type": "list", "elements": "str"}, + "column_alignments": {"type": "dict"}, + } + + self._options_context = self.validate_argument_spec(argument_spec) + + def _create_table(self, data: List[Dict[str, Any]]) -> prettytable.PrettyTable: + """Create and configure the prettytable instance. + + Args: + data: List of dictionaries to format into a table + + Returns: + Configured PrettyTable instance + """ + table = prettytable.PrettyTable() + + # Determine field names from data or column_order + field_names = self._task.args.get("column_order") or list(data[0].keys()) + + # Set headers + header_names = self._task.args.get("header_names") + table.field_names = header_names if header_names else field_names + + # Configure alignments + self._configure_alignments(table, field_names) + + # Add rows + rows = [[item.get(col, "") for col in field_names] for item in data] + table.add_rows(rows) + + return table + + def _configure_alignments( + self, table: prettytable.PrettyTable, field_names: List[str] + ) -> None: + """Configure column alignments for the table. + + Args: + table: The PrettyTable instance to configure + field_names: List of field names to align + """ + column_alignments = self._task.args.get("column_alignments", {}) + if not isinstance(column_alignments, dict): + return + + for col_name, alignment in column_alignments.items(): + if col_name in field_names: + alignment = alignment.lower() + if alignment in self._VALID_ALIGNMENTS: + table.align[col_name] = alignment[0] + else: + self._display.warning( + f"Ignored invalid alignment '{alignment}' for column '{col_name}'. " + f"Valid alignments are {list(self._VALID_ALIGNMENTS)}" + ) diff --git a/plugins/modules/prettytable.py b/plugins/modules/prettytable.py new file mode 100644 index 0000000000..c72d21d805 --- /dev/null +++ b/plugins/modules/prettytable.py @@ -0,0 +1,145 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2020, 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 + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = r''' +--- +module: prettytable +short_description: Format data into an ASCII table using prettytable +version_added: '10.6.0' +author: + - Timur Gadiev (@tgadiev) +extends_documentation_fragment: + - action_common_attributes + - action_common_attributes.conn + - action_common_attributes.flow + - community.general.attributes + - community.general.attributes.flow +description: + - This module formats a list of dictionaries into an ASCII table using the B(prettytable) Python library. + - Useful for creating human-readable output from structured data. + - Allows customization of column order, headers, and alignments. + - The table is created with a clean, readable format suitable for terminal output. +requirements: + - prettytable +attributes: + action: + support: full + async: + support: none + become: + support: none + bypass_host_loop: + support: none + check_mode: + support: full + connection: + support: none + delegation: + support: none + details: This is a pure action plugin that runs entirely on the controller. Delegation has no effect as no tasks are executed on remote hosts. + diff_mode: + support: none + platform: + support: full + platforms: all +options: + data: + description: + - List of dictionaries to format into a table. + - Each dictionary in the list represents a row in the table. + - Dictionary keys become column headers unless overridden by I(header_names). + - If the list is empty, an empty table will be created. + - All items in the list must be dictionaries. + type: list + elements: dict + required: true + column_order: + description: + - List of dictionary keys specifying the order of columns in the output table. + - If not specified, uses the keys from the first dictionary in the input list. + - Only the columns specified in this list will be included in the table. + - Keys must exist in the input dictionaries. + type: list + elements: str + required: false + header_names: + description: + - List of custom header names for the columns. + - Must match the length of columns being displayed. + - If not specified, uses the dictionary keys or I(column_order) values as headers. + - Use this to provide more readable or formatted column headers. + type: list + elements: str + required: false + column_alignments: + description: + - Dictionary mapping column names to their alignment. + - Keys should be column names (either from input data or I(column_order)). + - Values must be one of 'left', 'center', 'right' (or 'l', 'c', 'r'). + - Invalid alignment values will be ignored with a warning. + - Columns not specified default to left alignment. + - Alignments for non-existent columns are ignored. + type: dict + required: false +notes: + - This is an action plugin, meaning the plugin executes on the controller, rather than on the target host. + - The prettytable Python library must be installed on the controller. + - Column alignments are case-insensitive. + - Missing values in input dictionaries are displayed as empty strings. +seealso: + - module: ansible.builtin.debug +''' + +EXAMPLES = r''' +# Basic usage with a list of dictionaries +- name: Create a table from user data + community.general.prettytable: + data: + - name: Alice + age: 25 + role: admin + - name: Bob + age: 30 + role: user + +# Specify column order and custom headers +- name: Create a formatted table with custom headers + community.general.prettytable: + data: + - name: Alice + age: 25 + role: admin + - name: Bob + age: 30 + role: user + column_order: + - name + - role + - age + header_names: + - "User Name" + - "User Role" + - "User Age" + +# Set column alignments for better number and text formatting +- name: Create table with specific alignments + community.general.prettytable: + data: + - date: "2023-01-01" + description: "Office supplies" + amount: 123.45 + - date: "2023-01-02" + description: "Software license" + amount: 500.00 + column_alignments: + amount: right # Numbers right-aligned + description: left # Text left-aligned + date: center # Dates centered +''' From f316fc99e2b28981da1aa278f136032da6cc3eac Mon Sep 17 00:00:00 2001 From: Timur Gadiev <Timur_Gadiev@epam.com> Date: Tue, 1 Apr 2025 17:28:36 +0400 Subject: [PATCH 2/8] Add integration tests for 'prettytable' plugin --- plugins/modules/prettytable.py | 2 +- tests/integration/targets/prettytable/aliases | 1 + .../prettytable/files/aligned_table.txt | 5 + .../targets/prettytable/files/basic_table.txt | 6 + .../prettytable/files/custom_headers.txt | 5 + .../targets/prettytable/files/empty_table.txt | 2 + .../prettytable/files/ordered_table.txt | 6 + .../targets/prettytable/meta/main.yml | 1 + .../targets/prettytable/tasks/main.yml | 176 ++++++++++++++++++ 9 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 tests/integration/targets/prettytable/aliases create mode 100644 tests/integration/targets/prettytable/files/aligned_table.txt create mode 100644 tests/integration/targets/prettytable/files/basic_table.txt create mode 100644 tests/integration/targets/prettytable/files/custom_headers.txt create mode 100644 tests/integration/targets/prettytable/files/empty_table.txt create mode 100644 tests/integration/targets/prettytable/files/ordered_table.txt create mode 100644 tests/integration/targets/prettytable/meta/main.yml create mode 100644 tests/integration/targets/prettytable/tasks/main.yml diff --git a/plugins/modules/prettytable.py b/plugins/modules/prettytable.py index c72d21d805..8239ad8b65 100644 --- a/plugins/modules/prettytable.py +++ b/plugins/modules/prettytable.py @@ -119,7 +119,7 @@ EXAMPLES = r''' - name: Bob age: 30 role: user - column_order: + column_order: - name - role - age diff --git a/tests/integration/targets/prettytable/aliases b/tests/integration/targets/prettytable/aliases new file mode 100644 index 0000000000..4434c7969e --- /dev/null +++ b/tests/integration/targets/prettytable/aliases @@ -0,0 +1 @@ +azp/posix/1 \ No newline at end of file diff --git a/tests/integration/targets/prettytable/files/aligned_table.txt b/tests/integration/targets/prettytable/files/aligned_table.txt new file mode 100644 index 0000000000..c0394afe70 --- /dev/null +++ b/tests/integration/targets/prettytable/files/aligned_table.txt @@ -0,0 +1,5 @@ ++------------+-----------------+--------+ +| date | description | amount | ++------------+-----------------+--------+ +| 2023-01-01 | Office supplies | 123.45 | ++------------+-----------------+--------+ diff --git a/tests/integration/targets/prettytable/files/basic_table.txt b/tests/integration/targets/prettytable/files/basic_table.txt new file mode 100644 index 0000000000..29f39077ec --- /dev/null +++ b/tests/integration/targets/prettytable/files/basic_table.txt @@ -0,0 +1,6 @@ ++-------+-----+-------+ +| name | age | role | ++-------+-----+-------+ +| Alice | 25 | admin | +| Bob | 30 | user | ++-------+-----+-------+ diff --git a/tests/integration/targets/prettytable/files/custom_headers.txt b/tests/integration/targets/prettytable/files/custom_headers.txt new file mode 100644 index 0000000000..1630b327db --- /dev/null +++ b/tests/integration/targets/prettytable/files/custom_headers.txt @@ -0,0 +1,5 @@ ++-----------+----------+-----------+ +| User Name | User Age | User Role | ++-----------+----------+-----------+ +| Alice | 25 | admin | ++-----------+----------+-----------+ diff --git a/tests/integration/targets/prettytable/files/empty_table.txt b/tests/integration/targets/prettytable/files/empty_table.txt new file mode 100644 index 0000000000..9b52315d4f --- /dev/null +++ b/tests/integration/targets/prettytable/files/empty_table.txt @@ -0,0 +1,2 @@ +++ +++ diff --git a/tests/integration/targets/prettytable/files/ordered_table.txt b/tests/integration/targets/prettytable/files/ordered_table.txt new file mode 100644 index 0000000000..de7fab56f6 --- /dev/null +++ b/tests/integration/targets/prettytable/files/ordered_table.txt @@ -0,0 +1,6 @@ ++-------+-------+-----+ +| role | name | age | ++-------+-------+-----+ +| admin | Alice | 25 | +| user | Bob | 30 | ++-------+-------+-----+ diff --git a/tests/integration/targets/prettytable/meta/main.yml b/tests/integration/targets/prettytable/meta/main.yml new file mode 100644 index 0000000000..ed97d539c0 --- /dev/null +++ b/tests/integration/targets/prettytable/meta/main.yml @@ -0,0 +1 @@ +--- diff --git a/tests/integration/targets/prettytable/tasks/main.yml b/tests/integration/targets/prettytable/tasks/main.yml new file mode 100644 index 0000000000..6ced925790 --- /dev/null +++ b/tests/integration/targets/prettytable/tasks/main.yml @@ -0,0 +1,176 @@ +--- +- name: Install required libs + pip: + name: prettytable + state: present + delegate_to: localhost + become: false + +# Test basic functionality +- name: Test basic table creation + prettytable: + data: + - name: Alice + age: 25 + role: admin + - name: Bob + age: 30 + role: user + register: basic_result + +- name: Load expected basic table output + set_fact: + expected_basic_table: "{{ lookup('file', 'basic_table.txt') }}" + +- name: Verify basic table output + assert: + that: + - basic_result.pretty_table | trim == expected_basic_table | trim + - not basic_result.failed + +# Test column ordering +- name: Test table with custom column order + prettytable: + data: + - name: Alice + age: 25 + role: admin + - name: Bob + age: 30 + role: user + column_order: + - role + - name + - age + register: ordered_result + +- name: Load expected ordered table output + set_fact: + expected_ordered_table: "{{ lookup('file', 'ordered_table.txt') }}" + +- name: Verify ordered table output + assert: + that: + - ordered_result.pretty_table | trim == expected_ordered_table | trim + - not ordered_result.failed + +# Test custom headers +- name: Test table with custom headers + prettytable: + data: + - name: Alice + age: 25 + role: admin + header_names: + - "User Name" + - "User Age" + - "User Role" + register: headers_result + +- name: Load expected headers table output + set_fact: + expected_headers_table: "{{ lookup('file', 'custom_headers.txt') }}" + +- name: Verify custom headers output + assert: + that: + - headers_result.pretty_table | trim == expected_headers_table | trim + - not headers_result.failed + +# Test column alignments +- name: Test table with alignments + prettytable: + data: + - date: "2023-01-01" + description: "Office supplies" + amount: 123.45 + column_alignments: + amount: right + description: left + date: center + register: aligned_result + +- name: Load expected aligned table output + set_fact: + expected_aligned_table: "{{ lookup('file', 'aligned_table.txt') }}" + +- name: Verify aligned table output + assert: + that: + - aligned_result.pretty_table | trim == expected_aligned_table | trim + - not aligned_result.failed + +# Test error conditions +- name: Test missing data parameter (should fail) + prettytable: {} # Empty dict to test missing required parameter + register: missing_data_result + ignore_errors: true + +- name: Verify error for missing data + vars: + expected_msg: 'missing required arguments: data' + assert: + that: + - missing_data_result.failed + - expected_msg in missing_data_result.msg + +- name: Test invalid data type (should fail) + prettytable: + data: "this is a string" + register: invalid_data_result + ignore_errors: true + +- name: Verify error for invalid data + vars: + expected_msg: "Expected a list of dictionaries, got a string" + assert: + that: + - invalid_data_result.failed + - expected_msg == invalid_data_result.msg + +- name: Test list with invalid items (should fail) + prettytable: + data: + - {"valid": "dict"} + - "invalid string item" + register: invalid_items_result + ignore_errors: true + +- name: Verify error for invalid items + vars: + expected_msg: "All items in the list must be dictionaries, got a string" + assert: + that: + - invalid_items_result.failed + - expected_msg == invalid_items_result.msg + +# Test empty data +- name: Test empty data list + prettytable: + data: [] + register: empty_result + +- name: Load expected empty table output + set_fact: + expected_empty_table: "{{ lookup('file', 'empty_table.txt') }}" + +- name: Verify empty table output + assert: + that: + - empty_result.pretty_table | trim == expected_empty_table | trim + - not empty_result.failed + +# Test invalid alignments +- name: Test invalid alignment value + prettytable: + data: + - col1: value1 + col2: value2 + column_alignments: + col1: invalid + register: invalid_alignment_result + +- name: Verify invalid alignment handling + assert: + that: + - not invalid_alignment_result.failed # Should not fail, just warn From 9607eb03e734aa9fd34531b882c4a7c50b1c478b Mon Sep 17 00:00:00 2001 From: Timur Gadiev <Timur_Gadiev@epam.com> Date: Tue, 1 Apr 2025 17:34:37 +0400 Subject: [PATCH 3/8] Added BOTMETA details --- .github/BOTMETA.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index c92ce76034..02b0ecb4c9 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -15,6 +15,8 @@ files: labels: action $actions/iptables_state.py: maintainers: quidame + $actions/prettytable.py: + maintainers: tgadiev $actions/shutdown.py: maintainers: nitzmahone samdoran aminvakil $becomes/: @@ -208,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: @@ -1133,6 +1137,8 @@ files: keywords: doas dragonfly freebsd iocage jail netbsd openbsd opnsense pfsense labels: bsd portinstall maintainers: $team_bsd berenddeboer + $modules/prettytable.py: + maintainers: tgadiev $modules/pritunl_: maintainers: Lowess $modules/profitbricks: From dc205f6d292a04be129cfeed822dbb662caf8974 Mon Sep 17 00:00:00 2001 From: Timur Gadiev <Timur_Gadiev@epam.com> Date: Tue, 1 Apr 2025 18:03:40 +0400 Subject: [PATCH 4/8] Add COPYRIGHT details --- tests/integration/targets/prettytable/aliases | 4 ++ .../prettytable/files/aligned_table.txt | 4 ++ .../targets/prettytable/files/basic_table.txt | 4 ++ .../prettytable/files/custom_headers.txt | 4 ++ .../targets/prettytable/files/empty_table.txt | 4 ++ .../prettytable/files/ordered_table.txt | 4 ++ .../targets/prettytable/meta/main.yml | 3 ++ .../targets/prettytable/tasks/main.yml | 39 ++++++++++++++++--- 8 files changed, 61 insertions(+), 5 deletions(-) diff --git a/tests/integration/targets/prettytable/aliases b/tests/integration/targets/prettytable/aliases index 4434c7969e..f992fa439d 100644 --- a/tests/integration/targets/prettytable/aliases +++ b/tests/integration/targets/prettytable/aliases @@ -1 +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 \ No newline at end of file diff --git a/tests/integration/targets/prettytable/files/aligned_table.txt b/tests/integration/targets/prettytable/files/aligned_table.txt index c0394afe70..50f096485e 100644 --- a/tests/integration/targets/prettytable/files/aligned_table.txt +++ b/tests/integration/targets/prettytable/files/aligned_table.txt @@ -1,3 +1,7 @@ +# 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 + +------------+-----------------+--------+ | date | description | amount | +------------+-----------------+--------+ diff --git a/tests/integration/targets/prettytable/files/basic_table.txt b/tests/integration/targets/prettytable/files/basic_table.txt index 29f39077ec..c6e3cefed4 100644 --- a/tests/integration/targets/prettytable/files/basic_table.txt +++ b/tests/integration/targets/prettytable/files/basic_table.txt @@ -1,3 +1,7 @@ +# 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 + +-------+-----+-------+ | name | age | role | +-------+-----+-------+ diff --git a/tests/integration/targets/prettytable/files/custom_headers.txt b/tests/integration/targets/prettytable/files/custom_headers.txt index 1630b327db..20d1c9a1d5 100644 --- a/tests/integration/targets/prettytable/files/custom_headers.txt +++ b/tests/integration/targets/prettytable/files/custom_headers.txt @@ -1,3 +1,7 @@ +# 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 + +-----------+----------+-----------+ | User Name | User Age | User Role | +-----------+----------+-----------+ diff --git a/tests/integration/targets/prettytable/files/empty_table.txt b/tests/integration/targets/prettytable/files/empty_table.txt index 9b52315d4f..7fb2ceb9d7 100644 --- a/tests/integration/targets/prettytable/files/empty_table.txt +++ b/tests/integration/targets/prettytable/files/empty_table.txt @@ -1,2 +1,6 @@ +# 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 + ++ ++ diff --git a/tests/integration/targets/prettytable/files/ordered_table.txt b/tests/integration/targets/prettytable/files/ordered_table.txt index de7fab56f6..1bb348736d 100644 --- a/tests/integration/targets/prettytable/files/ordered_table.txt +++ b/tests/integration/targets/prettytable/files/ordered_table.txt @@ -1,3 +1,7 @@ +# 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 + +-------+-------+-----+ | role | name | age | +-------+-------+-----+ diff --git a/tests/integration/targets/prettytable/meta/main.yml b/tests/integration/targets/prettytable/meta/main.yml index ed97d539c0..e1e4a50798 100644 --- a/tests/integration/targets/prettytable/meta/main.yml +++ b/tests/integration/targets/prettytable/meta/main.yml @@ -1 +1,4 @@ --- +# Copyright (c) 2020, Pavlo Bashynskyi (@levonet) <levonet@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 diff --git a/tests/integration/targets/prettytable/tasks/main.yml b/tests/integration/targets/prettytable/tasks/main.yml index 6ced925790..fc814519aa 100644 --- a/tests/integration/targets/prettytable/tasks/main.yml +++ b/tests/integration/targets/prettytable/tasks/main.yml @@ -1,4 +1,13 @@ --- +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +# Copyright (c) 2020, Pavlo Bashynskyi (@levonet) <levonet@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 @@ -20,7 +29,11 @@ - name: Load expected basic table output set_fact: - expected_basic_table: "{{ lookup('file', 'basic_table.txt') }}" + expected_basic_table_raw: "{{ lookup('file', 'basic_table.txt') }}" + +- name: Remove copyright header from expected output + set_fact: + expected_basic_table: "{{ expected_basic_table_raw.split('\n')[4:] | join('\n') }}" - name: Verify basic table output assert: @@ -46,7 +59,11 @@ - name: Load expected ordered table output set_fact: - expected_ordered_table: "{{ lookup('file', 'ordered_table.txt') }}" + expected_ordered_table_raw: "{{ lookup('file', 'ordered_table.txt') }}" + +- name: Remove copyright header from expected output + set_fact: + expected_ordered_table: "{{ expected_ordered_table_raw.split('\n')[4:] | join('\n') }}" - name: Verify ordered table output assert: @@ -69,7 +86,11 @@ - name: Load expected headers table output set_fact: - expected_headers_table: "{{ lookup('file', 'custom_headers.txt') }}" + expected_headers_table_raw: "{{ lookup('file', 'custom_headers.txt') }}" + +- name: Remove copyright header from expected output + set_fact: + expected_headers_table: "{{ expected_headers_table_raw.split('\n')[4:] | join('\n') }}" - name: Verify custom headers output assert: @@ -92,7 +113,11 @@ - name: Load expected aligned table output set_fact: - expected_aligned_table: "{{ lookup('file', 'aligned_table.txt') }}" + expected_aligned_table_raw: "{{ lookup('file', 'aligned_table.txt') }}" + +- name: Remove copyright header from expected output + set_fact: + expected_aligned_table: "{{ expected_aligned_table_raw.split('\n')[4:] | join('\n') }}" - name: Verify aligned table output assert: @@ -152,7 +177,11 @@ - name: Load expected empty table output set_fact: - expected_empty_table: "{{ lookup('file', 'empty_table.txt') }}" + expected_empty_table_raw: "{{ lookup('file', 'empty_table.txt') }}" + +- name: Remove copyright header from expected output + set_fact: + expected_empty_table: "{{ expected_empty_table_raw.split('\n')[4:] | join('\n') }}" - name: Verify empty table output assert: From 94e7200e42191390672a7a1e84b78f43cd2656d4 Mon Sep 17 00:00:00 2001 From: Timur Gadiev <Timur_Gadiev@epam.com> Date: Tue, 1 Apr 2025 19:24:31 +0400 Subject: [PATCH 5/8] Add 'to_prettytable' filter plugin and tests --- plugins/filter/to_prettytable.py | 182 ++++++++++++++++++ .../targets/filter_to_prettytable/aliases | 5 + .../filter_to_prettytable/tasks/main.yml | 130 +++++++++++++ 3 files changed, 317 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/plugins/filter/to_prettytable.py b/plugins/filter/to_prettytable.py new file mode 100644 index 0000000000..ac5cc5a585 --- /dev/null +++ b/plugins/filter/to_prettytable.py @@ -0,0 +1,182 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025, Timur Gadiev <timur@example.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 + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' + name: to_prettytable + short_description: Format a list of dictionaries as an ASCII table + version_added: "8.0.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 of column alignments. Keys are column names, values are alignments. + type: dictionary + suboptions: + alignment: + description: Alignment for the column. Must be one of C(left), C(center), C(right), C(l), C(c), or C(r). + type: string + choices: [left, center, right, l, c, r] +''' + +EXAMPLES = ''' +- name: Display a list of users as a table + vars: + users: + - name: Alice + age: 25 + role: admin + - name: Bob + age: 30 + role: user + debug: + msg: "{{ users | community.general.to_prettytable }}" + +- name: Display a table with custom column ordering + debug: + msg: "{{ users | community.general.to_prettytable('role', 'name', 'age') }}" + +- name: Display a table with custom headers + debug: + msg: "{{ users | community.general.to_prettytable(header_names=['User Name', 'User Age', 'User Role']) }}" + +- name: Display a table with custom alignments + debug: + msg: "{{ users | community.general.to_prettytable(column_alignments={'name': 'center', 'age': 'right', 'role': 'left'}) }}" + +- name: Combine multiple options + 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 + + +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' + ) + + if not isinstance(data, list): + raise AnsibleFilterError( + "Expected a list of dictionaries, got a string" + if isinstance(data, string_types) + else f"Expected a list of dictionaries, got {type(data).__name__}" + ) + + # Handle empty data + if not data: + return "++\n++" + + # Check that all items are dictionaries + if not all(isinstance(item, dict) for item in data): + invalid_item = next(item for item in data if not isinstance(item, dict)) + raise AnsibleFilterError( + "All items in the list must be dictionaries, got a string" + if isinstance(invalid_item, string_types) + else f"All items in the list must be dictionaries, got {type(invalid_item).__name__}" + ) + + # Handle positional argument column order + column_order = kwargs.get('column_order', None) + if args and not column_order: + column_order = list(args) + + # Create the table and configure it + table = prettytable.PrettyTable() + + # Determine field names + field_names = column_order or list(data[0].keys()) + + # Set headers + header_names = kwargs.get('header_names', None) + table.field_names = header_names if header_names else field_names + + # Configure alignments + _configure_alignments(table, field_names, kwargs.get('column_alignments', {})) + + # Add rows + rows = [[item.get(col, "") for col in field_names] for item in data] + table.add_rows(rows) + + return to_text(table) + + +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: + alignment = alignment.lower() + if alignment in valid_alignments: + table.align[col_name] = alignment[0] + + +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..fd0f385267 --- /dev/null +++ b/tests/integration/targets/filter_to_prettytable/tasks/main.yml @@ -0,0 +1,130 @@ +--- +#################################################################### +# 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) +# 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 }}' + +- name: Verify basic table output + assert: + that: + - basic_table | trim is defined + - basic_table | trim is not none + +# Test column ordering +- name: Test column ordering + set_fact: + ordered_table: "{{ test_data | community.general.to_prettytable(column_order=['role', 'name', 'age']) }}" + +- name: Verify ordered table output + assert: + that: + - ordered_table | trim is defined + - ordered_table | trim is not none + - ordered_table | trim != basic_table | trim + +# 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']) }}" + +- name: Verify custom headers output + assert: + that: + - headers_table | trim is defined + - headers_table | trim is not none + - "headers_table | trim is search('User Name')" + - "headers_table | trim is search('User Age')" + - "headers_table | trim is search('User Role')" + +# 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'}) }}" + +- name: Verify aligned table output + assert: + that: + - aligned_table | trim is defined + - aligned_table | trim is not none + +# 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={'name': 'center', 'age': 'right', 'role': 'left'}) }}" + +- name: Verify combined table output + assert: + that: + - combined_table | trim is defined + - combined_table | trim is not none + - "combined_table | trim is search('Position')" + - "combined_table | trim is search('Full Name')" + - "combined_table | trim is search('Years')" + +# Test empty data +- name: Test empty data list + set_fact: + empty_table: "{{ [] | community.general.to_prettytable }}" + +- name: Verify empty table output + assert: + that: + - empty_table | trim == "++\n++" | trim + +# 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 + - "'All items in the list must be dictionaries' in failure_result.msg" From 47073e747dce323453f25915ec20c35448c09d42 Mon Sep 17 00:00:00 2001 From: Timur Gadiev <Timur_Gadiev@epam.com> Date: Tue, 1 Apr 2025 19:49:46 +0400 Subject: [PATCH 6/8] fix: :bug: Fix add_rows method --- plugins/action/prettytable.py | 5 +++-- plugins/filter/to_prettytable.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/plugins/action/prettytable.py b/plugins/action/prettytable.py index cb210e30bc..07d5e98bed 100644 --- a/plugins/action/prettytable.py +++ b/plugins/action/prettytable.py @@ -114,9 +114,10 @@ class ActionModule(ActionBase): # Configure alignments self._configure_alignments(table, field_names) - # Add rows + # Add rows - use add_row instead of add_rows for compatibility with older versions rows = [[item.get(col, "") for col in field_names] for item in data] - table.add_rows(rows) + for row in rows: + table.add_row(row) return table diff --git a/plugins/filter/to_prettytable.py b/plugins/filter/to_prettytable.py index ac5cc5a585..27599092a2 100644 --- a/plugins/filter/to_prettytable.py +++ b/plugins/filter/to_prettytable.py @@ -146,9 +146,10 @@ def to_prettytable(data, *args, **kwargs): # Configure alignments _configure_alignments(table, field_names, kwargs.get('column_alignments', {})) - # Add rows + # Add rows - use add_row instead of add_rows for compatibility with older versions rows = [[item.get(col, "") for col in field_names] for item in data] - table.add_rows(rows) + for row in rows: + table.add_row(row) return to_text(table) From aed3475767a564b1c316513393f91cbaf17f0604 Mon Sep 17 00:00:00 2001 From: Timur Gadiev <Timur_Gadiev@epam.com> Date: Tue, 1 Apr 2025 20:52:24 +0400 Subject: [PATCH 7/8] Add changelog fragment --- changelogs/fragments/9954-prettytable_plugins.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelogs/fragments/9954-prettytable_plugins.yml diff --git a/changelogs/fragments/9954-prettytable_plugins.yml b/changelogs/fragments/9954-prettytable_plugins.yml new file mode 100644 index 0000000000..0d5746dc9e --- /dev/null +++ b/changelogs/fragments/9954-prettytable_plugins.yml @@ -0,0 +1,4 @@ +--- +minor_changes: + - prettytable - New action plugin and module to format data as ASCII tables using the PrettyTable library + - to_prettytable filter - New filter plugin to format data as ASCII tables in Jinja2 templates using the PrettyTable library From 9eb454038624f2cab41173b5cbd7fed5e333788b Mon Sep 17 00:00:00 2001 From: Timur Gadiev <Timur_Gadiev@epam.com> Date: Tue, 1 Apr 2025 20:53:56 +0400 Subject: [PATCH 8/8] Remove changelog fragments --- changelogs/fragments/9954-prettytable_plugins.yml | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 changelogs/fragments/9954-prettytable_plugins.yml diff --git a/changelogs/fragments/9954-prettytable_plugins.yml b/changelogs/fragments/9954-prettytable_plugins.yml deleted file mode 100644 index 0d5746dc9e..0000000000 --- a/changelogs/fragments/9954-prettytable_plugins.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -minor_changes: - - prettytable - New action plugin and module to format data as ASCII tables using the PrettyTable library - - to_prettytable filter - New filter plugin to format data as ASCII tables in Jinja2 templates using the PrettyTable library