From 3d4b3656725d9ea7f630da04237e2435622b5cb6 Mon Sep 17 00:00:00 2001 From: Nocchia <133043574+NomakCooper@users.noreply.github.com> Date: Fri, 4 Apr 2025 20:23:09 +0000 Subject: [PATCH 1/5] open_xl - add op to create new excel file --- plugins/modules/open_xl.py | 80 +++++++++++++++---- .../targets/open_xl/tasks/tests.yml | 36 ++++++++- tests/unit/plugins/modules/test_open_xl.py | 66 +++++++++------ 3 files changed, 143 insertions(+), 39 deletions(-) diff --git a/plugins/modules/open_xl.py b/plugins/modules/open_xl.py index 4540b29..bba67a4 100644 --- a/plugins/modules/open_xl.py +++ b/plugins/modules/open_xl.py @@ -11,13 +11,14 @@ DOCUMENTATION = r''' --- module: open_xl -short_description: Read and update Excel files using openpyxl +short_description: Read, Create and update Excel files using openpyxl author: - "Marco Noce (@NomakCooper)" requirements: - openpyxl description: - This module reads from or writes to Excel (.xlsx) files using the openpyxl Python library. + - Create new Excel file is avaible from ans2dev.general 0.2.0. - It supports reading the entire workbook or a single worksheet, optionally limited to a given cell range. - For updates, you can overwrite cells, append new rows, or insert rows. You can also apply custom cell styles. - The original Excel file is not overwritten unless you set O(dest) to the same path as O(src). @@ -27,7 +28,7 @@ src: description: - Path to the source Excel file. - required: true + required: false type: str dest: description: @@ -36,20 +37,21 @@ required: false type: str op: - description: > - The operation to perform on the Excel file. Options: - V(r) - Read-only. Returns the content from the specified sheet or all sheets. - V(w) - Write. Overwrites specified cells with new values. - V(a) - Append. Creates one new row at the end of the sheet, writing each item in O(updates_matrix) to that row. - V(i) - Insert. Inserts a new row above the row specified in the first item of O(updates_matrix) and writes the updates. + description: + - The operation to perform on the Excel file. + - V(r) Read-only. Returns the content from the specified sheet or all sheets. + - V(w) Write. Overwrites specified cells with new values. + - V(a) Append. Creates one new row at the end of the sheet, writing each item in O(updates_matrix) to that row. + - V(i) Insert. Inserts a new row above the row specified in the first item of O(updates_matrix) and writes the updates. + - V(n) New. Create a new Excel file without O(src) file, avaible from ans2dev.general 0.2.0. required: true type: str - choices: ['r', 'w', 'a', 'i'] + choices: ['r', 'w', 'a', 'i', 'n'] sheet_name: description: - Name of the worksheet to operate on. - For O(op=r), if omitted, all sheets are read. - - For O(op=w), O(op=a), or O(op=i), this parameter is required. + - For O(op=w), O(op=a), O(op=i) and O(op=n), this parameter is required. required: false type: str index_by_name: @@ -151,6 +153,17 @@ cell_value: "Row" cell_style: italic: true + +# Create excel file from ans2dev.general 0.2.0 +- name: Create a new Excel file and write data + ans2dev.general.open_xl: + dest: "/tmp/new_file.xlsx" + op: "n" + sheet_name: "Data" + updates_matrix: + - cell_row: 1 + cell_col: 1 + cell_value: "Header" ''' RETURN = r''' @@ -296,7 +309,7 @@ def update_excel(module, src, dest, updates_matrix, cell_style, sheet_name, op): cell.value = update.get('cell_value', None) apply_cell_style(cell, cell_style) else: - module.fail_json(msg="Invalid operation: %s" % op) + module.fail_json(msg="Invalid operation for update_excel: %s" % op) if not dest: dest = src.rsplit('.', 1)[0] + '_updated.xlsx' @@ -309,12 +322,39 @@ def update_excel(module, src, dest, updates_matrix, cell_style, sheet_name, op): return {} +def new_excel(module, dest, updates_matrix, cell_style, sheet_name): + wb = openpyxl.Workbook() + if sheet_name: + wb.active.title = sheet_name + else: + sheet_name = wb.active.title + sheet = wb[sheet_name] + + for update in updates_matrix: + row = int(update.get('cell_row', 0)) + col = int(update.get('cell_col', 0)) + + if row < 1 or col < 1: + module.fail_json(msg="Invalid cell_row or cell_col in new file operation.") + + cell = sheet.cell(row=row, column=col) + cell.value = update.get('cell_value', None) + apply_cell_style(cell, cell_style) + + try: + wb.save(dest) + except Exception as e: + module.fail_json(msg="Error saving new workbook: %s" % str(e)) + + return {} + + def main(): module = AnsibleModule( argument_spec=dict( - src=dict(required=True, type='str'), + src=dict(required=False, type='str'), dest=dict(required=False, type='str'), - op=dict(required=True, type='str', choices=['r', 'w', 'a', 'i']), + op=dict(required=True, type='str', choices=['r', 'w', 'a', 'i', 'n']), sheet_name=dict(required=False, type='str'), index_by_name=dict(required=False, type='bool', default=True), read_range=dict(required=False, type='dict', default={}), @@ -342,11 +382,21 @@ def main(): updates_matrix = module.params.get('updates_matrix') or [] cell_style = module.params.get('cell_style') or {} - if op == 'r': + if op == 'n': + if not dest: + module.fail_json(msg="Parameter 'dest' is required when creating a new file (op: 'n').") + if not sheet_name: + module.fail_json(msg="Parameter 'sheet_name' is required when creating a new file (op: 'n').") + result = new_excel(module, dest, updates_matrix, cell_style, sheet_name) + elif op == 'r': + if not src: + module.fail_json(msg="Parameter 'src' is required for op 'r'.") result = read_excel(module, src, index_by_name, read_range, sheet_name) else: + if not src: + module.fail_json(msg="Parameter 'src' is required for op '%s'." % op) if not sheet_name: - module.fail_json(msg="Parameter sheet_name is required for write operations ('w', 'a', 'i').") + module.fail_json(msg="Parameter 'sheet_name' is required for op '%s'." % op) result = update_excel(module, src, dest, updates_matrix, cell_style, sheet_name, op) module.exit_json(changed=True, result=result) diff --git a/tests/integration/targets/open_xl/tasks/tests.yml b/tests/integration/targets/open_xl/tasks/tests.yml index b0e6862..0598c30 100644 --- a/tests/integration/targets/open_xl/tasks/tests.yml +++ b/tests/integration/targets/open_xl/tasks/tests.yml @@ -109,4 +109,38 @@ - name: Display final Excel content ansible.builtin.debug: - var: read_after_insert.result \ No newline at end of file + var: read_after_insert.result + +- name: Create a new Excel file using op "n" + ans2dev.general.open_xl: + dest: "/tmp/new_file.xlsx" + op: "n" + sheet_name: "Data" + updates_matrix: + - cell_row: 1 + cell_col: 1 + cell_value: "Header" + register: create_excel_result + +- name: Verify that the new Excel file was created + stat: + path: "/tmp/new_file.xlsx" + register: new_file_stat + +- name: Assert that the Excel file exists + assert: + that: + - new_file_stat.stat.exists + +- name: Read back the newly created Excel file + ans2dev.general.open_xl: + src: "/tmp/new_file.xlsx" + op: "r" + sheet_name: "Data" + index_by_name: false + register: read_excel_result + +- name: Assert that the cell (1,1) contains "Header" + assert: + that: + - read_excel_result.result.Data[0]['col_1'] == "Header" \ No newline at end of file diff --git a/tests/unit/plugins/modules/test_open_xl.py b/tests/unit/plugins/modules/test_open_xl.py index 775f02d..c949725 100644 --- a/tests/unit/plugins/modules/test_open_xl.py +++ b/tests/unit/plugins/modules/test_open_xl.py @@ -9,7 +9,6 @@ from unittest.mock import patch, MagicMock from openpyxl import Workbook - from ansible_collections.ans2dev.general.plugins.modules import open_xl # type: ignore @@ -25,21 +24,20 @@ class TestOpenXLModule(unittest.TestCase): @patch("ansible_collections.ans2dev.general.plugins.modules.open_xl.openpyxl.load_workbook") def test_read_excel(self, mock_load_workbook): - # Create a dummy workbook with one sheet ("Sheet1"). + wb = Workbook() ws = wb.active ws.title = "Sheet1" - # Header row. + ws.cell(row=1, column=1, value="Name") ws.cell(row=1, column=2, value="Age") - # Data rows. + ws.cell(row=2, column=1, value="Alice") ws.cell(row=2, column=2, value=30) ws.cell(row=3, column=1, value="Bob") ws.cell(row=3, column=2, value=25) mock_load_workbook.return_value = wb - # Patch AnsibleModule in open_xl. with patch.object(open_xl, 'AnsibleModule') as mock_AnsibleModule: fake_module = MagicMock() fake_module.params = { @@ -57,7 +55,6 @@ def test_read_excel(self, mock_load_workbook): open_xl.main() result_str = str(context.exception) - # Check that the result contains the expected sheet data. self.assertIn("exit_json called", result_str) self.assertIn("'Sheet1': [{'Name': 'Alice'", result_str) self.assertIn("'Age': 30", result_str) @@ -98,14 +95,12 @@ def test_write_excel(self, mock_load_workbook): open_xl.main() result_str = str(context.exception) - # Check that exit_json was called and that the workbook was saved correctly. self.assertIn("exit_json called", result_str) self.assertIn("result': {}", result_str) wb.save.assert_called_with('/tmp/dummy_updated.xlsx') @patch("ansible_collections.ans2dev.general.plugins.modules.open_xl.openpyxl.load_workbook") def test_append_excel(self, mock_load_workbook): - # Create a dummy workbook with one sheet ("Sheet1") and one data row. wb = Workbook() ws = wb.active ws.title = "Sheet1" @@ -135,7 +130,6 @@ def test_append_excel(self, mock_load_workbook): result_str = str(context.exception) self.assertIn("exit_json called", result_str) - # The new row should be at the end. new_row = ws.max_row appended_value = ws.cell(row=new_row, column=1).value self.assertEqual(appended_value, 'Appended') @@ -143,7 +137,6 @@ def test_append_excel(self, mock_load_workbook): @patch("ansible_collections.ans2dev.general.plugins.modules.open_xl.openpyxl.load_workbook") def test_insert_excel(self, mock_load_workbook): - # Create a dummy workbook with one sheet ("Sheet1") and two rows. wb = Workbook() ws = wb.active ws.title = "Sheet1" @@ -173,14 +166,12 @@ def test_insert_excel(self, mock_load_workbook): open_xl.main() result_str = str(context.exception) self.assertIn("exit_json called", result_str) - # After insertion, the new row should be at row 2. inserted_value = ws.cell(row=2, column=1).value self.assertEqual(inserted_value, 'Inserted') wb.save.assert_called_with('/tmp/dummy_updated.xlsx') @patch("ansible_collections.ans2dev.general.plugins.modules.open_xl.openpyxl.load_workbook") def test_invalid_sheet(self, mock_load_workbook): - # Create a dummy workbook with a sheet that does not match the provided sheet name. wb = Workbook() ws = wb.active ws.title = "ExistingSheet" @@ -207,7 +198,6 @@ def test_invalid_sheet(self, mock_load_workbook): @patch("ansible_collections.ans2dev.general.plugins.modules.open_xl.openpyxl.load_workbook") def test_empty_updates_matrix_for_insert(self, mock_load_workbook): - # Create a dummy workbook with one sheet. wb = Workbook() ws = wb.active ws.title = "Sheet1" @@ -222,7 +212,7 @@ def test_empty_updates_matrix_for_insert(self, mock_load_workbook): 'sheet_name': 'Sheet1', 'index_by_name': True, 'read_range': {}, - 'updates_matrix': [], # empty updates_matrix + 'updates_matrix': [], 'cell_style': {} } fake_module.exit_json.side_effect = exit_json @@ -237,7 +227,6 @@ def test_empty_updates_matrix_for_insert(self, mock_load_workbook): @patch("ansible_collections.ans2dev.general.plugins.modules.open_xl.openpyxl.load_workbook") def test_invalid_cell_in_write(self, mock_load_workbook): - # Create a dummy workbook with one sheet. wb = Workbook() ws = wb.active ws.title = "Sheet1" @@ -245,7 +234,6 @@ def test_invalid_cell_in_write(self, mock_load_workbook): with patch.object(open_xl, 'AnsibleModule') as mock_AnsibleModule: fake_module = MagicMock() - # Provide invalid cell_row (0) for write operation. fake_module.params = { 'src': '/tmp/dummy.xlsx', 'dest': '/tmp/dummy_updated.xlsx', @@ -268,7 +256,6 @@ def test_invalid_cell_in_write(self, mock_load_workbook): @patch("ansible_collections.ans2dev.general.plugins.modules.open_xl.openpyxl.load_workbook") def test_default_dest_naming(self, mock_load_workbook): - # Create a dummy workbook with one sheet. wb = Workbook() ws = wb.active ws.title = "Sheet1" @@ -279,7 +266,6 @@ def test_default_dest_naming(self, mock_load_workbook): with patch.object(open_xl, 'AnsibleModule') as mock_AnsibleModule: fake_module = MagicMock() - # Do not provide 'dest' to trigger default naming. fake_module.params = { 'src': '/tmp/dummy.xlsx', 'op': 'w', @@ -297,12 +283,10 @@ def test_default_dest_naming(self, mock_load_workbook): open_xl.main() result_str = str(context.exception) self.assertIn("exit_json called", result_str) - # Expect the default destination to be /tmp/dummy_updated.xlsx. wb.save.assert_called_with('/tmp/dummy_updated.xlsx') @patch("ansible_collections.ans2dev.general.plugins.modules.open_xl.openpyxl.load_workbook") def test_workbook_load_error(self, mock_load_workbook): - # Simulate an exception when loading the workbook. mock_load_workbook.side_effect = Exception("Load error") with patch.object(open_xl, 'AnsibleModule') as mock_AnsibleModule: @@ -326,7 +310,6 @@ def test_workbook_load_error(self, mock_load_workbook): @patch("ansible_collections.ans2dev.general.plugins.modules.open_xl.openpyxl.load_workbook") def test_cell_style_application(self, mock_load_workbook): - # Create a dummy workbook with one sheet. wb = Workbook() ws = wb.active ws.title = "Sheet1" @@ -362,9 +345,9 @@ def test_cell_style_application(self, mock_load_workbook): result_str = str(context.exception) self.assertIn("exit_json called", result_str) wb.save.assert_called_with('/tmp/dummy_updated.xlsx') - # Verify that the cell style was applied. + cell = ws.cell(row=2, column=1) - # Note: openpyxl prepends "00" to color values. + self.assertEqual(cell.font.color.rgb, '00FF0000') self.assertTrue(cell.font.bold) self.assertTrue(cell.font.italic) @@ -372,6 +355,43 @@ def test_cell_style_application(self, mock_load_workbook): # Check fill attribute. self.assertEqual(cell.fill.fgColor.rgb, '0000FF00') + @patch("ansible_collections.ans2dev.general.plugins.modules.open_xl.Workbook") + def test_new_excel(self, mock_Workbook): + + fake_wb = MagicMock() + fake_ws = MagicMock() + fake_wb.active = fake_ws + + fake_wb.__getitem__.return_value = fake_ws + + fake_cell = MagicMock() + fake_ws.cell.return_value = fake_cell + mock_Workbook.return_value = fake_wb + + with patch.object(open_xl, 'AnsibleModule') as mock_AnsibleModule: + fake_module = MagicMock() + fake_module.params = { + 'dest': '/tmp/new_file.xlsx', + 'op': 'n', + 'sheet_name': 'Data', + 'updates_matrix': [{'cell_row': 1, 'cell_col': 1, 'cell_value': 'Header'}], + 'cell_style': {} + } + fake_module.exit_json.side_effect = exit_json + fake_module.fail_json.side_effect = fail_json + mock_AnsibleModule.return_value = fake_module + + with self.assertRaises(Exception) as context: + open_xl.main() + + result_str = str(context.exception) + self.assertIn("exit_json called", result_str) + + fake_wb.save.assert_called_with('/tmp/new_file.xlsx') + + fake_ws.cell.assert_called_with(row=1, column=1) + self.assertEqual(fake_cell.value, 'Header') + if __name__ == '__main__': unittest.main() From a9f4bb49701a305abc7542a505c8a19eeee8c242 Mon Sep 17 00:00:00 2001 From: Nocchia <133043574+NomakCooper@users.noreply.github.com> Date: Fri, 4 Apr 2025 20:36:31 +0000 Subject: [PATCH 2/5] open_xl - fix docs and ansible-test units --- plugins/modules/open_xl.py | 24 +++++++++++----------- tests/unit/plugins/modules/test_open_xl.py | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/plugins/modules/open_xl.py b/plugins/modules/open_xl.py index bba67a4..117b1fa 100644 --- a/plugins/modules/open_xl.py +++ b/plugins/modules/open_xl.py @@ -70,23 +70,23 @@ type: dict default: {} updates_matrix: - description: > - A list of dictionaries describing the cells to update. Each dictionary can include: - V(cell_row) - The row to update (ignored in append mode). - V(cell_col) - The column to update. - V(cell_value) - The value to write. + description: + - A list of dictionaries describing the cells to update.Each dictionary can include + - V(cell_row) The row to update (ignored in append mode). + - V(cell_col) The column to update. + - V(cell_value) The value to write. required: false type: list elements: dict default: [] cell_style: - description: > - A dictionary specifying optional style attributes for updated cells. Possible keys include: - V(fontColor) - Hex RGB code for the font color. - V(bgColor) - Hex RGB code for the cell background color. - V(bold) - Boolean to set bold font. - V(italic) - Boolean to set italic font. - V(underline) - Boolean to set underline; if true, uses single underline. + description: + - A dictionary specifying optional style attributes for updated cells. Possible keys include + - V(fontColor) Hex RGB code for the font color. + - V(bgColor) Hex RGB code for the cell background color. + - V(bold) Boolean to set bold font. + - V(italic) Boolean to set italic font. + - V(underline) Boolean to set underline; if true, uses single underline. required: false type: dict default: {} diff --git a/tests/unit/plugins/modules/test_open_xl.py b/tests/unit/plugins/modules/test_open_xl.py index c949725..19fa13e 100644 --- a/tests/unit/plugins/modules/test_open_xl.py +++ b/tests/unit/plugins/modules/test_open_xl.py @@ -355,7 +355,7 @@ def test_cell_style_application(self, mock_load_workbook): # Check fill attribute. self.assertEqual(cell.fill.fgColor.rgb, '0000FF00') - @patch("ansible_collections.ans2dev.general.plugins.modules.open_xl.Workbook") + @patch("ansible_collections.ans2dev.general.plugins.modules.open_xl.openpyxl.Workbook") def test_new_excel(self, mock_Workbook): fake_wb = MagicMock() From 363983e771b0103cc1d67f7959e8da868bec567f Mon Sep 17 00:00:00 2001 From: Nocchia <133043574+NomakCooper@users.noreply.github.com> Date: Fri, 4 Apr 2025 20:42:41 +0000 Subject: [PATCH 3/5] open_xl - fix ansible-test units --- tests/unit/plugins/modules/test_open_xl.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/plugins/modules/test_open_xl.py b/tests/unit/plugins/modules/test_open_xl.py index 19fa13e..6d00b86 100644 --- a/tests/unit/plugins/modules/test_open_xl.py +++ b/tests/unit/plugins/modules/test_open_xl.py @@ -375,7 +375,9 @@ def test_new_excel(self, mock_Workbook): 'op': 'n', 'sheet_name': 'Data', 'updates_matrix': [{'cell_row': 1, 'cell_col': 1, 'cell_value': 'Header'}], - 'cell_style': {} + 'cell_style': {}, + 'index_by_name': True, + 'read_range': {} } fake_module.exit_json.side_effect = exit_json fake_module.fail_json.side_effect = fail_json @@ -383,7 +385,6 @@ def test_new_excel(self, mock_Workbook): with self.assertRaises(Exception) as context: open_xl.main() - result_str = str(context.exception) self.assertIn("exit_json called", result_str) From acc641509303107e8c90d76a80fe4e1a81945be4 Mon Sep 17 00:00:00 2001 From: Nocchia <133043574+NomakCooper@users.noreply.github.com> Date: Fri, 4 Apr 2025 20:47:52 +0000 Subject: [PATCH 4/5] open_xl - fix ansible-test units --- tests/unit/plugins/modules/test_open_xl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/plugins/modules/test_open_xl.py b/tests/unit/plugins/modules/test_open_xl.py index 6d00b86..99a9620 100644 --- a/tests/unit/plugins/modules/test_open_xl.py +++ b/tests/unit/plugins/modules/test_open_xl.py @@ -357,7 +357,6 @@ def test_cell_style_application(self, mock_load_workbook): @patch("ansible_collections.ans2dev.general.plugins.modules.open_xl.openpyxl.Workbook") def test_new_excel(self, mock_Workbook): - fake_wb = MagicMock() fake_ws = MagicMock() fake_wb.active = fake_ws @@ -371,6 +370,7 @@ def test_new_excel(self, mock_Workbook): with patch.object(open_xl, 'AnsibleModule') as mock_AnsibleModule: fake_module = MagicMock() fake_module.params = { + 'src': '/tmp/dummy.xlsx', 'dest': '/tmp/new_file.xlsx', 'op': 'n', 'sheet_name': 'Data', From ad7e962fd63a0089fa3e17ff486aa95043a3db82 Mon Sep 17 00:00:00 2001 From: Nocchia <133043574+NomakCooper@users.noreply.github.com> Date: Fri, 4 Apr 2025 20:59:05 +0000 Subject: [PATCH 5/5] open_xl - add changelog/fragments file --- changelogs/fragments/116-open_xl-add-file-creation.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelogs/fragments/116-open_xl-add-file-creation.yml diff --git a/changelogs/fragments/116-open_xl-add-file-creation.yml b/changelogs/fragments/116-open_xl-add-file-creation.yml new file mode 100644 index 0000000..1f9a4eb --- /dev/null +++ b/changelogs/fragments/116-open_xl-add-file-creation.yml @@ -0,0 +1,2 @@ +minor_changes: + - open_xl - add new ``n`` value for ``op`` option to create new excel file without ``src`` (https://github.com/3A2DEV/ans2dev.general/pull/116). \ No newline at end of file