From a6e835e6eba0664b56df3650aad5dcd621dd180c Mon Sep 17 00:00:00 2001 From: Jose Drowne Date: Mon, 19 Jan 2026 14:36:48 -0500 Subject: [PATCH 01/17] Add toml_file module for managing TOML file settings New module to manage individual settings in TOML files without managing the entire file. Features include: - Add, modify, or remove keys in TOML tables - Support for all TOML 1.0.0 value types (strings, integers, floats, booleans, datetimes, arrays, inline tables, etc.) - Special float values (inf, -inf, nan) - Alternative integer representations (hex, octal, binary) - String variants (literal, multiline, multiline literal) - Array of tables support with indexing - Nested tables and dotted key support - Comment and formatting preservation via tomlkit - Backup file creation - Check mode and diff mode support Co-Authored-By: Claude Opus 4.5 --- .github/BOTMETA.yml | 2 + .mypy.ini | 2 + plugins/modules/toml_file.py | 803 ++++++++++++++++++ tests/integration/targets/toml_file/aliases | 5 + .../targets/toml_file/meta/main.yml | 7 + .../targets/toml_file/tasks/00-basic.yml | 188 ++++ .../targets/toml_file/tasks/01-tables.yml | 133 +++ .../targets/toml_file/tasks/02-nested.yml | 148 ++++ .../targets/toml_file/tasks/03-arrays.yml | 141 +++ .../toml_file/tasks/04-array-tables.yml | 187 ++++ .../targets/toml_file/tasks/05-types.yml | 573 +++++++++++++ .../targets/toml_file/tasks/06-comments.yml | 127 +++ .../targets/toml_file/tasks/07-backup.yml | 108 +++ .../targets/toml_file/tasks/main.yml | 81 ++ 14 files changed, 2505 insertions(+) create mode 100644 plugins/modules/toml_file.py create mode 100644 tests/integration/targets/toml_file/aliases create mode 100644 tests/integration/targets/toml_file/meta/main.yml create mode 100644 tests/integration/targets/toml_file/tasks/00-basic.yml create mode 100644 tests/integration/targets/toml_file/tasks/01-tables.yml create mode 100644 tests/integration/targets/toml_file/tasks/02-nested.yml create mode 100644 tests/integration/targets/toml_file/tasks/03-arrays.yml create mode 100644 tests/integration/targets/toml_file/tasks/04-array-tables.yml create mode 100644 tests/integration/targets/toml_file/tasks/05-types.yml create mode 100644 tests/integration/targets/toml_file/tasks/06-comments.yml create mode 100644 tests/integration/targets/toml_file/tasks/07-backup.yml create mode 100644 tests/integration/targets/toml_file/tasks/main.yml diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 2cd7bbeabe..e3a2f5f900 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -738,6 +738,8 @@ files: maintainers: resmo $modules/ini_file.py: maintainers: jpmens noseka1 + $modules/toml_file.py: + maintainers: jdrowne $modules/installp.py: keywords: aix efix lpar wpar labels: aix installp diff --git a/.mypy.ini b/.mypy.ini index e3128f32ff..381cde50e1 100644 --- a/.mypy.ini +++ b/.mypy.ini @@ -216,6 +216,8 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-thycotic.*] ignore_missing_imports = True +[mypy-tomlkit.*] +ignore_missing_imports = True [mypy-univention.*] ignore_missing_imports = True [mypy-vexatapi.*] diff --git a/plugins/modules/toml_file.py b/plugins/modules/toml_file.py new file mode 100644 index 0000000000..c52c7e3cc0 --- /dev/null +++ b/plugins/modules/toml_file.py @@ -0,0 +1,803 @@ +#!/usr/bin/python + +# Copyright (c) 2026, Jose Drowne +# 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 + +DOCUMENTATION = r""" +module: toml_file +short_description: Manage individual settings in TOML files +extends_documentation_fragment: + - ansible.builtin.files + - community.general.attributes +description: + - Manage (add, remove, change) individual settings in a TOML file without having to manage the file as a whole + with, say, M(ansible.builtin.template) or M(ansible.builtin.assemble). + - Adds missing tables if they do not exist. + - Comments and formatting are preserved using the C(tomlkit) library. +attributes: + check_mode: + support: full + diff_mode: + support: full +options: + path: + description: + - Path to the TOML file; this file is created if required. + type: path + required: true + aliases: [dest] + table: + description: + - Table name in the TOML file. + - Use dotted notation for nested tables (for example, V(server.database)). + - If omitted, the key is set at the document root level. + type: str + key: + description: + - The key to manage within the table (or document root if O(table) is not specified). + - Use dotted notation to address nested keys (for example, V(connection.timeout)). + - May be omitted when removing an entire table with O(state=absent). + type: str + value: + description: + - The value to set for the key. + - YAML types are automatically converted to appropriate TOML types. + - Use O(value_type) to force a specific type. + - Required when O(state=present) and O(key) is specified. + type: raw + value_type: + description: + - Force the value to be a specific TOML type. + - V(auto) automatically detects the type from the input value. + - V(string) forces the value to be a TOML string. + - V(literal_string) creates a TOML literal string (single quotes, no escape processing). + - V(multiline_string) creates a TOML multi-line basic string (triple double quotes). + - V(multiline_literal_string) creates a TOML multi-line literal string (triple single quotes). + - V(integer) forces the value to be a TOML integer. + - V(hex_integer) forces the value to be a TOML hexadecimal integer (for example, V(0xDEADBEEF)). + - V(octal_integer) forces the value to be a TOML octal integer (for example, V(0o755)). + - V(binary_integer) forces the value to be a TOML binary integer (for example, V(0b11010110)). + - V(float) forces the value to be a TOML float. Supports V(inf), V(-inf), and V(nan). + - V(boolean) forces the value to be a TOML boolean. + - V(datetime) parses the value as an ISO 8601 offset or local datetime. + - V(date) parses the value as a local date (for example, V(1979-05-27)). + - V(time) parses the value as a local time (for example, V(07:32:00)). + - V(array) ensures the value is a TOML array. + - V(inline_table) ensures the value is a TOML inline table. + type: str + choices: + - auto + - string + - literal_string + - multiline_string + - multiline_literal_string + - integer + - hex_integer + - octal_integer + - binary_integer + - float + - boolean + - datetime + - date + - time + - array + - inline_table + default: auto + state: + description: + - If set to V(absent), the specified key (or entire table if no key is specified) is removed. + - If set to V(present), the specified key is added or updated. + type: str + choices: [absent, present] + default: present + backup: + description: + - Create a backup file including the timestamp information so you can get the original file back + if you somehow clobbered it incorrectly. + type: bool + default: false + create: + description: + - If set to V(false), the module fails if the file does not already exist. + - By default it creates the file if it is missing. + type: bool + default: true + follow: + description: + - This flag indicates that filesystem links, if they exist, should be followed. + - O(follow=true) can modify O(path) when combined with parameters such as O(mode). + type: bool + default: false + array_table_index: + description: + - For array of tables (C([[table]])), specify which entry to modify. + - Use V(-1) to append a new entry to the array of tables. + - Use V(0) for the first entry, V(1) for the second, and so on. + - If the table is not an array of tables, this parameter is ignored. + type: int +requirements: + - tomlkit +notes: + - The C(tomlkit) library preserves comments and formatting in TOML files. + - When O(state=present) and O(key) is specified, O(value) must also be provided. +seealso: + - module: community.general.ini_file +author: + - Jose Drowne (@jdrowne) +""" + +EXAMPLES = r""" +- name: Set server host in TOML file + community.general.toml_file: + path: /etc/myapp/config.toml + table: server + key: host + value: localhost + +- name: Set nested configuration value + community.general.toml_file: + path: /etc/myapp/config.toml + table: server.database + key: port + value: 5432 + value_type: integer + +- name: Set a boolean value + community.general.toml_file: + path: /etc/myapp/config.toml + table: features + key: debug + value: true + value_type: boolean + +- name: Set an array value + community.general.toml_file: + path: /etc/myapp/config.toml + table: server + key: allowed_hosts + value: + - localhost + - 127.0.0.1 + - "::1" + +- name: Set a value at document root level + community.general.toml_file: + path: /etc/myapp/config.toml + key: title + value: My Application + +- name: Remove a key from a table + community.general.toml_file: + path: /etc/myapp/config.toml + table: server + key: deprecated_option + state: absent + +- name: Remove an entire table + community.general.toml_file: + path: /etc/myapp/config.toml + table: obsolete_section + state: absent + +- name: Add entry to array of tables + community.general.toml_file: + path: /etc/myapp/config.toml + table: products + key: name + value: Hammer + array_table_index: -1 + +- name: Modify first entry in array of tables + community.general.toml_file: + path: /etc/myapp/config.toml + table: products + key: price + value: 9.99 + array_table_index: 0 + +- name: Set an inline table + community.general.toml_file: + path: /etc/myapp/config.toml + table: database + key: connection + value: + host: localhost + port: 5432 + value_type: inline_table + +- name: Ensure config exists with backup + community.general.toml_file: + path: /etc/myapp/config.toml + table: server + key: port + value: 8080 + backup: true + +- name: Set infinity value + community.general.toml_file: + path: /etc/myapp/config.toml + table: limits + key: max_value + value: inf + value_type: float + +- name: Set a date value + community.general.toml_file: + path: /etc/myapp/config.toml + table: project + key: start_date + value: "2026-01-17" + value_type: date + +- name: Set a time value + community.general.toml_file: + path: /etc/myapp/config.toml + table: schedule + key: daily_backup + value: "03:00:00" + value_type: time + +- name: Set a literal string (no escape processing) + community.general.toml_file: + path: /etc/myapp/config.toml + table: paths + key: regex + value: '\\d+\\.\\d+' + value_type: literal_string + +- name: Set a multiline string + community.general.toml_file: + path: /etc/myapp/config.toml + table: messages + key: welcome + value: | + Welcome to our application! + We hope you enjoy using it. + value_type: multiline_string + +- name: Set a hexadecimal integer + community.general.toml_file: + path: /etc/myapp/config.toml + table: display + key: color + value: 16777215 + value_type: hex_integer + +- name: Set an octal integer (file permissions) + community.general.toml_file: + path: /etc/myapp/config.toml + table: files + key: mode + value: 493 + value_type: octal_integer + +- name: Set a binary integer + community.general.toml_file: + path: /etc/myapp/config.toml + table: flags + key: bits + value: 170 + value_type: binary_integer +""" + +RETURN = r""" +path: + description: The path to the TOML file. + returned: always + type: str + sample: /etc/myapp/config.toml +msg: + description: A message describing what was done. + returned: always + type: str + sample: key added +backup_file: + description: The name of the backup file that was created. + returned: when backup=true and file was modified + type: str + sample: /etc/myapp/config.toml.2026-01-17@12:34:56~ +diff: + description: The differences between the old and new file. + returned: when diff mode is enabled + type: dict + contains: + before: + description: The content of the file before modification. + type: str + after: + description: The content of the file after modification. + type: str +""" + +import os +import tempfile +import traceback +from datetime import datetime +from typing import Any + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.common.text.converters import to_bytes + +from ansible_collections.community.general.plugins.module_utils import deps + +with deps.declare("tomlkit"): + import tomlkit + from tomlkit.items import AoT, Array, InlineTable, Integer, Table, Trivia + + +class TomlFileError(Exception): + pass + + +def load_toml_document(path: str, create: bool = True) -> Any: + if not os.path.exists(path): + if not create: + raise TomlFileError(f"Destination {path} does not exist!") + return tomlkit.document() + + try: + with open(path, encoding="utf-8") as f: + return tomlkit.parse(f.read()) + except Exception as e: + raise TomlFileError(f"Failed to parse TOML file: {e}") from e + + +def navigate_to_table( + doc: Any, table_path: str | None, create: bool = False, array_table_index: int | None = None +) -> Any: + if not table_path: + return doc + + parts = table_path.split(".") + current = doc + + for i, part in enumerate(parts): + is_last = i == len(parts) - 1 + + if part not in current: + if not create: + raise TomlFileError(f"Table '{'.'.join(parts[: i + 1])}' does not exist") + + if is_last and array_table_index == -1: + new_table = tomlkit.table() + aot = tomlkit.aot() + aot.append(new_table) + current[part] = aot + current = new_table + else: + new_table = tomlkit.table() + current[part] = new_table + current = new_table + else: + item = current[part] + if isinstance(item, AoT): + if array_table_index is not None: + if array_table_index == -1: + if is_last: + new_table = tomlkit.table() + item.append(new_table) + current = new_table + else: + if len(item) == 0: + raise TomlFileError(f"Array of tables '{'.'.join(parts[: i + 1])}' is empty") + current = item[-1] + elif array_table_index < len(item): + current = item[array_table_index] + else: + raise TomlFileError( + f"Array of tables index {array_table_index} is out of range for '{'.'.join(parts[: i + 1])}'" + ) + else: + if len(item) == 0: + raise TomlFileError(f"Array of tables '{'.'.join(parts[: i + 1])}' is empty") + current = item[0] + elif isinstance(item, (Table, dict)): + current = item + else: + raise TomlFileError(f"'{'.'.join(parts[: i + 1])}' is not a table") + + return current + + +def convert_value(value: Any, value_type: str = "auto") -> Any: + if value_type == "auto": + if isinstance(value, (bool, int, float)): + return value + elif isinstance(value, str): + try: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + if "T" in value or " " in value: + return dt + except (ValueError, AttributeError): + pass + return value + elif isinstance(value, list): + arr = tomlkit.array() + for item in value: + arr.append(convert_value(item, "auto")) + return arr + elif isinstance(value, dict): + tbl = tomlkit.inline_table() + for k, v in value.items(): + tbl[k] = convert_value(v, "auto") + return tbl + else: + return str(value) + elif value_type == "string": + return str(value) + elif value_type == "literal_string": + return tomlkit.string(str(value), literal=True) + elif value_type == "multiline_string": + return tomlkit.string(str(value), multiline=True) + elif value_type == "multiline_literal_string": + return tomlkit.string(str(value), literal=True, multiline=True) + elif value_type == "integer": + return int(value) + elif value_type == "hex_integer": + int_val = int(value) if isinstance(value, int) else int(str(value), 16) + return Integer(int_val, Trivia(), hex(int_val)) + elif value_type == "octal_integer": + int_val = int(value) if isinstance(value, int) else int(str(value), 8) + return Integer(int_val, Trivia(), oct(int_val)) + elif value_type == "binary_integer": + int_val = int(value) if isinstance(value, int) else int(str(value), 2) + return Integer(int_val, Trivia(), bin(int_val)) + elif value_type == "float": + if isinstance(value, str): + return float(value.lower()) + return float(value) + elif value_type == "boolean": + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() in ("true", "yes", "1", "on") + return bool(value) + elif value_type == "datetime": + if isinstance(value, datetime): + return value + return datetime.fromisoformat(str(value).replace("Z", "+00:00")) + elif value_type == "date": + from datetime import date as date_type + + if isinstance(value, date_type) and not isinstance(value, datetime): + return tomlkit.date(value.isoformat()) + return tomlkit.date(str(value)) + elif value_type == "time": + from datetime import time as time_type + + if isinstance(value, time_type): + return tomlkit.time(value.isoformat()) + return tomlkit.time(str(value)) + elif value_type == "array": + if isinstance(value, list): + arr = tomlkit.array() + for item in value: + arr.append(convert_value(item, "auto")) + return arr + arr = tomlkit.array() + arr.append(convert_value(value, "auto")) + return arr + elif value_type == "inline_table": + if isinstance(value, dict): + tbl = tomlkit.inline_table() + for k, v in value.items(): + tbl[k] = convert_value(v, "auto") + return tbl + raise ValueError(f"Cannot convert {type(value).__name__} to inline_table") + else: + return value + + +def navigate_to_key_parent(target: Any, key: str) -> tuple[Any, str]: + parts = key.split(".") + if len(parts) == 1: + return target, key + + parent = target + for i, part in enumerate(parts[:-1]): + if part not in parent: + new_table = tomlkit.inline_table() + parent[part] = new_table + parent = new_table + else: + item = parent[part] + if isinstance(item, (Table, InlineTable, dict)): + parent = item + else: + raise TomlFileError(f"'{'.'.join(parts[: i + 1])}' is not a table, cannot navigate further") + + return parent, parts[-1] + + +def set_key_value(target: Any, key: str, value: Any, value_type: str = "auto") -> bool: + parent, final_key = navigate_to_key_parent(target, key) + converted_value = convert_value(value, value_type) + + if final_key in parent: + existing = parent[final_key] + if _values_equal(existing, converted_value): + return False + + parent[final_key] = converted_value + return True + + +def _values_equal(a: Any, b: Any) -> bool: + if isinstance(a, Array) and isinstance(b, (Array, list)): + a_list = list(a) + b_list = list(b) if isinstance(b, Array) else b + if len(a_list) != len(b_list): + return False + return all(_values_equal(x, y) for x, y in zip(a_list, b_list)) + elif isinstance(a, (InlineTable, Table)) and isinstance(b, (InlineTable, Table, dict)): + a_dict = dict(a) + b_dict = dict(b) if isinstance(b, (InlineTable, Table)) else b + if set(a_dict.keys()) != set(b_dict.keys()): + return False + return all(_values_equal(a_dict[k], b_dict[k]) for k in a_dict) + else: + try: + return a == b + except Exception: + return False + + +def remove_key(target: Any, key: str) -> bool: + parts = key.split(".") + parent = target + for part in parts[:-1]: + if part not in parent: + return False + item = parent[part] + if isinstance(item, (Table, InlineTable, dict)): + parent = item + else: + return False + + final_key = parts[-1] + if final_key in parent: + del parent[final_key] + return True + return False + + +def remove_table(doc: Any, table_path: str | None, array_table_index: int | None = None) -> bool: + if not table_path: + raise TomlFileError("Cannot remove document root") + + parts = table_path.split(".") + if len(parts) == 1: + if parts[0] in doc: + item = doc[parts[0]] + if isinstance(item, AoT) and array_table_index is not None: + if array_table_index == -1: + if len(item) > 0: + del item[-1] + return True + return False + elif 0 <= array_table_index < len(item): + del item[array_table_index] + return True + raise TomlFileError(f"Array of tables index {array_table_index} out of range") + del doc[parts[0]] + return True + return False + + try: + parent = navigate_to_table(doc, ".".join(parts[:-1]), create=False) + except TomlFileError: + return False + + final_part = parts[-1] + if final_part in parent: + item = parent[final_part] + if isinstance(item, AoT) and array_table_index is not None: + if array_table_index == -1: + if len(item) > 0: + del item[-1] + return True + return False + elif 0 <= array_table_index < len(item): + del item[array_table_index] + return True + raise TomlFileError(f"Array of tables index {array_table_index} out of range") + del parent[final_part] + return True + return False + + +def do_toml( + module: AnsibleModule, + path: str, + table: str | None, + key: str | None, + value: Any, + value_type: str, + state: str, + backup: bool, + create: bool, + follow: bool, + array_table_index: int | None, +) -> tuple[bool, str | None, dict[str, str], str]: + diff = { + "before": "", + "after": "", + "before_header": f"{path} (content)", + "after_header": f"{path} (content)", + } + + if follow and os.path.islink(path): + target_path = os.path.realpath(path) + else: + target_path = path + + try: + doc = load_toml_document(target_path, create) + + if module._diff: + diff["before"] = tomlkit.dumps(doc) + + original_content = tomlkit.dumps(doc) + changed = False + msg = "OK" + + if state == "present": + if key: + if value is None: + raise TomlFileError("Parameter 'value' is required when state=present and key is specified") + + target = navigate_to_table(doc, table, create=True, array_table_index=array_table_index) + changed = set_key_value(target, key, value, value_type) + + if changed: + msg = "key added" if key not in target or changed else "key changed" + else: + if table: + navigate_to_table(doc, table, create=True, array_table_index=array_table_index) + new_content = tomlkit.dumps(doc) + if new_content != original_content: + changed = True + msg = "table added" + else: + msg = "nothing to do" + elif state == "absent": + if key: + try: + target = navigate_to_table(doc, table, create=False, array_table_index=array_table_index) + changed = remove_key(target, key) + msg = "key removed" if changed else "key not found" + except TomlFileError: + msg = "key not found" + else: + if table: + changed = remove_table(doc, table, array_table_index) + msg = "table removed" if changed else "table not found" + else: + msg = "nothing to do" + + if module._diff: + diff["after"] = tomlkit.dumps(doc) + + backup_file = None + if changed and not module.check_mode: + if backup and os.path.exists(target_path): + backup_file = module.backup_local(target_path) + + destpath = os.path.dirname(target_path) + if destpath and not os.path.exists(destpath): + os.makedirs(destpath) + + content = tomlkit.dumps(doc) + encoded_content = to_bytes(content) + + try: + tmpfd, tmpfile = tempfile.mkstemp(dir=module.tmpdir) + with os.fdopen(tmpfd, "wb") as f: + f.write(encoded_content) + except OSError: + module.fail_json(msg="Unable to create temporary file", traceback=traceback.format_exc()) + + try: + module.atomic_move(tmpfile, os.path.abspath(target_path)) + except OSError: + module.fail_json( + msg=f"Unable to move temporary file {tmpfile} to {target_path}", + traceback=traceback.format_exc(), + ) + + return changed, backup_file, diff, msg + + except TomlFileError as e: + return False, None, diff, str(e) + + +def main() -> None: + module = AnsibleModule( + argument_spec=dict( + path=dict(type="path", required=True, aliases=["dest"]), + table=dict(type="str"), + key=dict(type="str", no_log=False), + value=dict(type="raw"), + value_type=dict( + type="str", + default="auto", + choices=[ + "auto", + "string", + "literal_string", + "multiline_string", + "multiline_literal_string", + "integer", + "hex_integer", + "octal_integer", + "binary_integer", + "float", + "boolean", + "datetime", + "date", + "time", + "array", + "inline_table", + ], + ), + state=dict(type="str", default="present", choices=["absent", "present"]), + backup=dict(type="bool", default=False), + create=dict(type="bool", default=True), + follow=dict(type="bool", default=False), + array_table_index=dict(type="int"), + ), + add_file_common_args=True, + supports_check_mode=True, + ) + + deps.validate(module) + + path = module.params["path"] + table = module.params["table"] + key = module.params["key"] + value = module.params["value"] + value_type = module.params["value_type"] + state = module.params["state"] + backup = module.params["backup"] + create = module.params["create"] + follow = module.params["follow"] + array_table_index = module.params["array_table_index"] + + changed, backup_file, diff, msg = do_toml( + module, path, table, key, value, value_type, state, backup, create, follow, array_table_index + ) + + if msg and msg not in [ + "OK", + "key added", + "key changed", + "key removed", + "key not found", + "table added", + "table removed", + "table not found", + "nothing to do", + ]: + module.fail_json(msg=msg) + + if not module.check_mode and os.path.exists(path): + file_args = module.load_file_common_arguments(module.params) + changed = module.set_fs_attributes_if_different(file_args, changed) + + results = dict( + changed=changed, + diff=diff, + msg=msg, + path=path, + ) + if backup_file is not None: + results["backup_file"] = backup_file + + module.exit_json(**results) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/toml_file/aliases b/tests/integration/targets/toml_file/aliases new file mode 100644 index 0000000000..12d1d6617e --- /dev/null +++ b/tests/integration/targets/toml_file/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/2 diff --git a/tests/integration/targets/toml_file/meta/main.yml b/tests/integration/targets/toml_file/meta/main.yml new file mode 100644 index 0000000000..982de6eb03 --- /dev/null +++ b/tests/integration/targets/toml_file/meta/main.yml @@ -0,0 +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 + +dependencies: + - setup_remote_tmp_dir diff --git a/tests/integration/targets/toml_file/tasks/00-basic.yml b/tests/integration/targets/toml_file/tasks/00-basic.yml new file mode 100644 index 0000000000..195e7be359 --- /dev/null +++ b/tests/integration/targets/toml_file/tasks/00-basic.yml @@ -0,0 +1,188 @@ +--- +# 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 + +## Basic tests - create, update, remove key + +- name: test-basic 1 - set "create=false" on non-existing file and fail + toml_file: + path: "{{ non_existing_file }}" + table: server + key: host + value: localhost + create: false + register: result_basic_1 + ignore_errors: true + +- name: test-basic 1 - verify error message + assert: + that: + - result_basic_1 is not changed + - result_basic_1 is failed + - "'does not exist' in result_basic_1.msg" + +- name: test-basic 2 - create a new TOML file with a key + toml_file: + path: "{{ output_file }}" + table: server + key: host + value: localhost + register: result_basic_2 + +- name: test-basic 2 - verify file created + assert: + that: + - result_basic_2 is changed + - result_basic_2.msg == "key added" + +- name: test-basic 2 - read and verify content + slurp: + src: "{{ output_file }}" + register: content_basic_2 + +- name: test-basic 2 - verify TOML content + vars: + expected: | + [server] + host = "localhost" + assert: + that: + - (content_basic_2.content | b64decode) == expected + +- name: test-basic 3 - set the same key again (idempotent) + toml_file: + path: "{{ output_file }}" + table: server + key: host + value: localhost + register: result_basic_3 + +- name: test-basic 3 - verify no change + assert: + that: + - result_basic_3 is not changed + +- name: test-basic 4 - update the key value + toml_file: + path: "{{ output_file }}" + table: server + key: host + value: "192.168.1.1" + register: result_basic_4 + +- name: test-basic 4 - verify change + assert: + that: + - result_basic_4 is changed + +- name: test-basic 4 - read and verify content + slurp: + src: "{{ output_file }}" + register: content_basic_4 + +- name: test-basic 4 - verify updated TOML content + vars: + expected: | + [server] + host = "192.168.1.1" + assert: + that: + - (content_basic_4.content | b64decode) == expected + +- name: test-basic 5 - remove the key + toml_file: + path: "{{ output_file }}" + table: server + key: host + state: absent + register: result_basic_5 + +- name: test-basic 5 - verify removal + assert: + that: + - result_basic_5 is changed + - result_basic_5.msg == "key removed" + +- name: test-basic 5 - read and verify content + slurp: + src: "{{ output_file }}" + register: content_basic_5 + +- name: test-basic 5 - verify key removed from TOML + vars: + expected: | + [server] + assert: + that: + - (content_basic_5.content | b64decode) == expected + +- name: test-basic 6 - remove non-existing key (idempotent) + toml_file: + path: "{{ output_file }}" + table: server + key: nonexistent + state: absent + register: result_basic_6 + +- name: test-basic 6 - verify no change + assert: + that: + - result_basic_6 is not changed + - result_basic_6.msg == "key not found" + +- name: test-basic 7 - set key at document root + toml_file: + path: "{{ output_file }}" + key: title + value: "My Application" + register: result_basic_7 + +- name: test-basic 7 - verify change + assert: + that: + - result_basic_7 is changed + +- name: test-basic 7 - read and verify content + slurp: + src: "{{ output_file }}" + register: content_basic_7 + +- name: test-basic 7 - verify root-level key + vars: + expected: | + title = "My Application" + + [server] + assert: + that: + - (content_basic_7.content | b64decode) == expected + +- name: test-basic 8 - check mode test (no actual change) + toml_file: + path: "{{ output_file }}" + table: server + key: port + value: 8080 + check_mode: true + register: result_basic_8 + +- name: test-basic 8 - verify check mode reports change + assert: + that: + - result_basic_8 is changed + +- name: test-basic 8 - verify file was not actually modified + slurp: + src: "{{ output_file }}" + register: content_basic_8 + +- name: test-basic 8 - verify port not in file + vars: + expected: | + title = "My Application" + + [server] + assert: + that: + - (content_basic_8.content | b64decode) == expected diff --git a/tests/integration/targets/toml_file/tasks/01-tables.yml b/tests/integration/targets/toml_file/tasks/01-tables.yml new file mode 100644 index 0000000000..32c3847b23 --- /dev/null +++ b/tests/integration/targets/toml_file/tasks/01-tables.yml @@ -0,0 +1,133 @@ +--- +# 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 + +## Table tests - create and remove tables + +- name: test-table 1 - create a table with a key + toml_file: + path: "{{ output_file }}" + table: database + key: host + value: localhost + register: result_table_1 + +- name: test-table 1 - verify change + assert: + that: + - result_table_1 is changed + +- name: test-table 1 - read content + slurp: + src: "{{ output_file }}" + register: content_table_1 + +- name: test-table 1 - verify table created + vars: + expected: | + [database] + host = "localhost" + assert: + that: + - (content_table_1.content | b64decode) == expected + +- name: test-table 2 - add another key to the same table + toml_file: + path: "{{ output_file }}" + table: database + key: port + value: 5432 + register: result_table_2 + +- name: test-table 2 - verify change + assert: + that: + - result_table_2 is changed + +- name: test-table 2 - read content + slurp: + src: "{{ output_file }}" + register: content_table_2 + +- name: test-table 2 - verify key added + vars: + expected: | + [database] + host = "localhost" + port = 5432 + assert: + that: + - (content_table_2.content | b64decode) == expected + +- name: test-table 3 - create a second table + toml_file: + path: "{{ output_file }}" + table: server + key: name + value: myserver + register: result_table_3 + +- name: test-table 3 - verify change + assert: + that: + - result_table_3 is changed + +- name: test-table 3 - read content + slurp: + src: "{{ output_file }}" + register: content_table_3 + +- name: test-table 3 - verify both tables exist + vars: + expected: | + [database] + host = "localhost" + port = 5432 + + [server] + name = "myserver" + assert: + that: + - (content_table_3.content | b64decode) == expected + +- name: test-table 4 - remove entire table + toml_file: + path: "{{ output_file }}" + table: server + state: absent + register: result_table_4 + +- name: test-table 4 - verify change + assert: + that: + - result_table_4 is changed + - result_table_4.msg == "table removed" + +- name: test-table 4 - read content + slurp: + src: "{{ output_file }}" + register: content_table_4 + +- name: test-table 4 - verify table removed + vars: + expected: | + [database] + host = "localhost" + port = 5432 + assert: + that: + - (content_table_4.content | b64decode) == expected + +- name: test-table 5 - remove non-existing table (idempotent) + toml_file: + path: "{{ output_file }}" + table: nonexistent + state: absent + register: result_table_5 + +- name: test-table 5 - verify no change + assert: + that: + - result_table_5 is not changed + - result_table_5.msg == "table not found" diff --git a/tests/integration/targets/toml_file/tasks/02-nested.yml b/tests/integration/targets/toml_file/tasks/02-nested.yml new file mode 100644 index 0000000000..6f938eb0be --- /dev/null +++ b/tests/integration/targets/toml_file/tasks/02-nested.yml @@ -0,0 +1,148 @@ +--- +# 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 + +## Nested tests - dotted paths for tables and keys + +- name: test-nested 1 - create nested table with dotted notation + toml_file: + path: "{{ output_file }}" + table: server.database + key: port + value: 5432 + register: result_nested_1 + +- name: test-nested 1 - verify change + assert: + that: + - result_nested_1 is changed + +- name: test-nested 1 - read content + slurp: + src: "{{ output_file }}" + register: content_nested_1 + +- name: test-nested 1 - verify nested table created + vars: + expected: | + [server.database] + port = 5432 + assert: + that: + - (content_nested_1.content | b64decode) == expected + +- name: test-nested 2 - add another key to nested table + toml_file: + path: "{{ output_file }}" + table: server.database + key: name + value: mydb + register: result_nested_2 + +- name: test-nested 2 - verify change + assert: + that: + - result_nested_2 is changed + +- name: test-nested 2 - read content + slurp: + src: "{{ output_file }}" + register: content_nested_2 + +- name: test-nested 2 - verify key added + vars: + expected: | + [server.database] + port = 5432 + name = "mydb" + assert: + that: + - (content_nested_2.content | b64decode) == expected + +- name: test-nested 3 - create deeply nested table + toml_file: + path: "{{ output_file }}" + table: app.config.logging + key: level + value: debug + register: result_nested_3 + +- name: test-nested 3 - verify change + assert: + that: + - result_nested_3 is changed + +- name: test-nested 3 - read content + slurp: + src: "{{ output_file }}" + register: content_nested_3 + +- name: test-nested 3 - verify deeply nested table + vars: + expected: | + [server.database] + port = 5432 + name = "mydb" + + [app.config.logging] + level = "debug" + assert: + that: + - (content_nested_3.content | b64decode) == expected + +- name: test-nested 4 - remove key from nested table + toml_file: + path: "{{ output_file }}" + table: server.database + key: name + state: absent + register: result_nested_4 + +- name: test-nested 4 - verify change + assert: + that: + - result_nested_4 is changed + +- name: test-nested 4 - read content + slurp: + src: "{{ output_file }}" + register: content_nested_4 + +- name: test-nested 4 - verify key removed + vars: + expected: | + [server.database] + port = 5432 + + [app.config.logging] + level = "debug" + assert: + that: + - (content_nested_4.content | b64decode) == expected + +- name: test-nested 5 - remove nested table + toml_file: + path: "{{ output_file }}" + table: server.database + state: absent + register: result_nested_5 + +- name: test-nested 5 - verify change + assert: + that: + - result_nested_5 is changed + +- name: test-nested 5 - read content + slurp: + src: "{{ output_file }}" + register: content_nested_5 + +- name: test-nested 5 - verify table removed + vars: + expected: | + [app.config.logging] + level = "debug" + assert: + that: + - (content_nested_5.content | b64decode) == expected diff --git a/tests/integration/targets/toml_file/tasks/03-arrays.yml b/tests/integration/targets/toml_file/tasks/03-arrays.yml new file mode 100644 index 0000000000..d31e241e95 --- /dev/null +++ b/tests/integration/targets/toml_file/tasks/03-arrays.yml @@ -0,0 +1,141 @@ +--- +# 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 + +## Array tests - array values + +- name: test-array 1 - set an array value + toml_file: + path: "{{ output_file }}" + table: server + key: ports + value: + - 80 + - 443 + - 8080 + register: result_array_1 + +- name: test-array 1 - verify change + assert: + that: + - result_array_1 is changed + +- name: test-array 1 - read content + slurp: + src: "{{ output_file }}" + register: content_array_1 + +- name: test-array 1 - verify array in TOML + vars: + expected: | + [server] + ports = [80, 443, 8080] + assert: + that: + - (content_array_1.content | b64decode) == expected + +- name: test-array 2 - set the same array again (idempotent) + toml_file: + path: "{{ output_file }}" + table: server + key: ports + value: + - 80 + - 443 + - 8080 + register: result_array_2 + +- name: test-array 2 - verify no change + assert: + that: + - result_array_2 is not changed + +- name: test-array 3 - update array value + toml_file: + path: "{{ output_file }}" + table: server + key: ports + value: + - 80 + - 443 + register: result_array_3 + +- name: test-array 3 - verify change + assert: + that: + - result_array_3 is changed + +- name: test-array 3 - read content + slurp: + src: "{{ output_file }}" + register: content_array_3 + +- name: test-array 3 - verify updated array + vars: + expected: | + [server] + ports = [80, 443] + assert: + that: + - (content_array_3.content | b64decode) == expected + +- name: test-array 4 - set array of strings + toml_file: + path: "{{ output_file }}" + table: server + key: allowed_hosts + value: + - localhost + - "127.0.0.1" + - "::1" + register: result_array_4 + +- name: test-array 4 - verify change + assert: + that: + - result_array_4 is changed + +- name: test-array 4 - read content + slurp: + src: "{{ output_file }}" + register: content_array_4 + +- name: test-array 4 - verify string array + vars: + expected: | + [server] + ports = [80, 443] + allowed_hosts = ["localhost", "127.0.0.1", "::1"] + assert: + that: + - (content_array_4.content | b64decode) == expected + +- name: test-array 5 - set empty array + toml_file: + path: "{{ output_file }}" + table: server + key: empty_list + value: [] + register: result_array_5 + +- name: test-array 5 - verify change + assert: + that: + - result_array_5 is changed + +- name: test-array 5 - read content + slurp: + src: "{{ output_file }}" + register: content_array_5 + +- name: test-array 5 - verify empty array + vars: + expected: | + [server] + ports = [80, 443] + allowed_hosts = ["localhost", "127.0.0.1", "::1"] + empty_list = [] + assert: + that: + - (content_array_5.content | b64decode) == expected diff --git a/tests/integration/targets/toml_file/tasks/04-array-tables.yml b/tests/integration/targets/toml_file/tasks/04-array-tables.yml new file mode 100644 index 0000000000..5b4b9363ce --- /dev/null +++ b/tests/integration/targets/toml_file/tasks/04-array-tables.yml @@ -0,0 +1,187 @@ +--- +# 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 + +## Array of tables tests - [[table]] handling + +- name: test-aot 1 - create first array of tables entry + toml_file: + path: "{{ output_file }}" + table: products + key: name + value: Hammer + array_table_index: -1 + register: result_aot_1 + +- name: test-aot 1 - verify change + assert: + that: + - result_aot_1 is changed + +- name: test-aot 1 - read content + slurp: + src: "{{ output_file }}" + register: content_aot_1 + +- name: test-aot 1 - verify array of tables created + vars: + expected: | + [[products]] + name = "Hammer" + assert: + that: + - (content_aot_1.content | b64decode) == expected + +- name: test-aot 2 - add key to existing entry + toml_file: + path: "{{ output_file }}" + table: products + key: price + value: 9.99 + array_table_index: 0 + register: result_aot_2 + +- name: test-aot 2 - verify change + assert: + that: + - result_aot_2 is changed + +- name: test-aot 2 - read content + slurp: + src: "{{ output_file }}" + register: content_aot_2 + +- name: test-aot 2 - verify price added + vars: + expected: | + [[products]] + name = "Hammer" + price = 9.99 + assert: + that: + - (content_aot_2.content | b64decode) == expected + +- name: test-aot 3 - append new entry to array of tables + toml_file: + path: "{{ output_file }}" + table: products + key: name + value: Nail + array_table_index: -1 + register: result_aot_3 + +- name: test-aot 3 - verify change + assert: + that: + - result_aot_3 is changed + +- name: test-aot 3 - read content + slurp: + src: "{{ output_file }}" + register: content_aot_3 + +- name: test-aot 3 - verify second entry added + vars: + expected: | + [[products]] + name = "Hammer" + price = 9.99 + + [[products]] + name = "Nail" + assert: + that: + - (content_aot_3.content | b64decode) == expected + +- name: test-aot 4 - modify second entry + toml_file: + path: "{{ output_file }}" + table: products + key: price + value: 0.05 + array_table_index: 1 + register: result_aot_4 + +- name: test-aot 4 - verify change + assert: + that: + - result_aot_4 is changed + +- name: test-aot 4 - read content + slurp: + src: "{{ output_file }}" + register: content_aot_4 + +- name: test-aot 4 - verify second entry modified + vars: + expected: | + [[products]] + name = "Hammer" + price = 9.99 + + [[products]] + name = "Nail" + price = 0.05 + assert: + that: + - (content_aot_4.content | b64decode) == expected + +- name: test-aot 5 - remove key from first entry + toml_file: + path: "{{ output_file }}" + table: products + key: price + state: absent + array_table_index: 0 + register: result_aot_5 + +- name: test-aot 5 - verify change + assert: + that: + - result_aot_5 is changed + +- name: test-aot 5 - read content + slurp: + src: "{{ output_file }}" + register: content_aot_5 + +- name: test-aot 5 - verify key removed from first entry + vars: + expected: | + [[products]] + name = "Hammer" + + [[products]] + name = "Nail" + price = 0.05 + assert: + that: + - (content_aot_5.content | b64decode) == expected + +- name: test-aot 6 - remove last entry from array of tables + toml_file: + path: "{{ output_file }}" + table: products + state: absent + array_table_index: -1 + register: result_aot_6 + +- name: test-aot 6 - verify change + assert: + that: + - result_aot_6 is changed + +- name: test-aot 6 - read content + slurp: + src: "{{ output_file }}" + register: content_aot_6 + +- name: test-aot 6 - verify last entry removed + vars: + expected: | + [[products]] + name = "Hammer" + assert: + that: + - (content_aot_6.content | b64decode) == expected diff --git a/tests/integration/targets/toml_file/tasks/05-types.yml b/tests/integration/targets/toml_file/tasks/05-types.yml new file mode 100644 index 0000000000..934c94e9fd --- /dev/null +++ b/tests/integration/targets/toml_file/tasks/05-types.yml @@ -0,0 +1,573 @@ +--- +# 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 + +## Type tests - explicit type coercion + +## Group 1: Basic types (integer, float, boolean, string) + +- name: test-type 1 - set integer value + toml_file: + path: "{{ output_file }}" + table: types + key: count + value: 42 + value_type: integer + register: result_type_1 + +- name: test-type 1 - verify change + assert: + that: + - result_type_1 is changed + +- name: test-type 1 - read content + slurp: + src: "{{ output_file }}" + register: content_type_1 + +- name: test-type 1 - verify integer + vars: + expected: | + [types] + count = 42 + assert: + that: + - (content_type_1.content | b64decode) == expected + +- name: test-type 2 - set float value + toml_file: + path: "{{ output_file }}" + table: types + key: ratio + value: 3.14159 + value_type: float + register: result_type_2 + +- name: test-type 2 - verify change + assert: + that: + - result_type_2 is changed + +- name: test-type 2 - read content + slurp: + src: "{{ output_file }}" + register: content_type_2 + +- name: test-type 2 - verify float + vars: + expected: | + [types] + count = 42 + ratio = 3.14159 + assert: + that: + - (content_type_2.content | b64decode) == expected + +- name: test-type 3 - set boolean true + toml_file: + path: "{{ output_file }}" + table: types + key: enabled + value: true + value_type: boolean + register: result_type_3 + +- name: test-type 3 - verify change + assert: + that: + - result_type_3 is changed + +- name: test-type 3 - read content + slurp: + src: "{{ output_file }}" + register: content_type_3 + +- name: test-type 3 - verify boolean + vars: + expected: | + [types] + count = 42 + ratio = 3.14159 + enabled = true + assert: + that: + - (content_type_3.content | b64decode) == expected + +- name: test-type 4 - set boolean false + toml_file: + path: "{{ output_file }}" + table: types + key: disabled + value: false + value_type: boolean + register: result_type_4 + +- name: test-type 4 - verify change + assert: + that: + - result_type_4 is changed + +- name: test-type 4 - read content + slurp: + src: "{{ output_file }}" + register: content_type_4 + +- name: test-type 4 - verify boolean false + vars: + expected: | + [types] + count = 42 + ratio = 3.14159 + enabled = true + disabled = false + assert: + that: + - (content_type_4.content | b64decode) == expected + +- name: test-type 5 - set string value (force string) + toml_file: + path: "{{ output_file }}" + table: types + key: number_as_string + value: "12345" + value_type: string + register: result_type_5 + +- name: test-type 5 - verify change + assert: + that: + - result_type_5 is changed + +- name: test-type 5 - read content + slurp: + src: "{{ output_file }}" + register: content_type_5 + +- name: test-type 5 - verify string (should be quoted) + vars: + expected: | + [types] + count = 42 + ratio = 3.14159 + enabled = true + disabled = false + number_as_string = "12345" + assert: + that: + - (content_type_5.content | b64decode) == expected + +## Group 2: Reset and test datetime, inline_table, array + +- name: Reset output file for group 2 + file: + path: "{{ output_file }}" + state: absent + +- name: test-type 6 - set datetime value + toml_file: + path: "{{ output_file }}" + table: types + key: timestamp + value: "2026-01-17T12:00:00" + value_type: datetime + register: result_type_6 + +- name: test-type 6 - verify change + assert: + that: + - result_type_6 is changed + +- name: test-type 6 - read content + slurp: + src: "{{ output_file }}" + register: content_type_6 + +- name: test-type 6 - verify datetime + assert: + that: + - "'timestamp = 2026-01-17' in (content_type_6.content | b64decode)" + +- name: test-type 7 - set inline table + toml_file: + path: "{{ output_file }}" + table: types + key: point + value: + x: 10 + y: 20 + value_type: inline_table + register: result_type_7 + +- name: test-type 7 - verify change + assert: + that: + - result_type_7 is changed + +- name: test-type 7 - read content + slurp: + src: "{{ output_file }}" + register: content_type_7 + +- name: test-type 7 - verify inline table + assert: + that: + - "'point = {' in (content_type_7.content | b64decode)" + - "'x = 10' in (content_type_7.content | b64decode)" + - "'y = 20' in (content_type_7.content | b64decode)" + +- name: test-type 8 - force value to array + toml_file: + path: "{{ output_file }}" + table: types + key: single_as_array + value: single_value + value_type: array + register: result_type_8 + +- name: test-type 8 - verify change + assert: + that: + - result_type_8 is changed + +- name: test-type 8 - read content + slurp: + src: "{{ output_file }}" + register: content_type_8 + +- name: test-type 8 - verify array + assert: + that: + - "'single_as_array = [\"single_value\"]' in (content_type_8.content | b64decode)" + +## Group 3: Reset and test special floats (inf, -inf, nan) + +- name: Reset output file for group 3 + file: + path: "{{ output_file }}" + state: absent + +- name: test-type 9 - set positive infinity + toml_file: + path: "{{ output_file }}" + table: types + key: max_limit + value: inf + value_type: float + register: result_type_9 + +- name: test-type 9 - verify change + assert: + that: + - result_type_9 is changed + +- name: test-type 9 - read content + slurp: + src: "{{ output_file }}" + register: content_type_9 + +- name: test-type 9 - verify infinity in file + vars: + expected: | + [types] + max_limit = inf + assert: + that: + - (content_type_9.content | b64decode) == expected + +- name: test-type 10 - set negative infinity + toml_file: + path: "{{ output_file }}" + table: types + key: min_limit + value: "-inf" + value_type: float + register: result_type_10 + +- name: test-type 10 - verify change + assert: + that: + - result_type_10 is changed + +- name: test-type 10 - read content + slurp: + src: "{{ output_file }}" + register: content_type_10 + +- name: test-type 10 - verify negative infinity in file + vars: + expected: | + [types] + max_limit = inf + min_limit = -inf + assert: + that: + - (content_type_10.content | b64decode) == expected + +- name: test-type 11 - set nan + toml_file: + path: "{{ output_file }}" + table: types + key: undefined + value: nan + value_type: float + register: result_type_11 + +- name: test-type 11 - verify change + assert: + that: + - result_type_11 is changed + +- name: test-type 11 - read content + slurp: + src: "{{ output_file }}" + register: content_type_11 + +- name: test-type 11 - verify nan in file + vars: + expected: | + [types] + max_limit = inf + min_limit = -inf + undefined = nan + assert: + that: + - (content_type_11.content | b64decode) == expected + +## Group 4: Reset and test date and time + +- name: Reset output file for group 4 + file: + path: "{{ output_file }}" + state: absent + +- name: test-type 12 - set local date + toml_file: + path: "{{ output_file }}" + table: types + key: birthday + value: "1990-05-15" + value_type: date + register: result_type_12 + +- name: test-type 12 - verify change + assert: + that: + - result_type_12 is changed + +- name: test-type 12 - read content + slurp: + src: "{{ output_file }}" + register: content_type_12 + +- name: test-type 12 - verify date in file + vars: + expected: | + [types] + birthday = 1990-05-15 + assert: + that: + - (content_type_12.content | b64decode) == expected + +- name: test-type 13 - set local time + toml_file: + path: "{{ output_file }}" + table: types + key: alarm + value: "07:30:00" + value_type: time + register: result_type_13 + +- name: test-type 13 - verify change + assert: + that: + - result_type_13 is changed + +- name: test-type 13 - read content + slurp: + src: "{{ output_file }}" + register: content_type_13 + +- name: test-type 13 - verify time in file + vars: + expected: | + [types] + birthday = 1990-05-15 + alarm = 07:30:00 + assert: + that: + - (content_type_13.content | b64decode) == expected + +## Group 5: Reset and test string variants + +- name: Reset output file for group 5 + file: + path: "{{ output_file }}" + state: absent + +- name: test-type 14 - set literal string + toml_file: + path: "{{ output_file }}" + table: types + key: regex_pattern + value: '\d+\.\d+' + value_type: literal_string + register: result_type_14 + +- name: test-type 14 - verify change + assert: + that: + - result_type_14 is changed + +- name: test-type 14 - read content + slurp: + src: "{{ output_file }}" + register: content_type_14 + +- name: test-type 14 - verify literal string uses single quotes + assert: + that: + - >- + "regex_pattern = '" in (content_type_14.content | b64decode) + +- name: test-type 15 - set multiline string + toml_file: + path: "{{ output_file }}" + table: types + key: description + value: "Line one\nLine two" + value_type: multiline_string + register: result_type_15 + +- name: test-type 15 - verify change + assert: + that: + - result_type_15 is changed + +- name: test-type 15 - read content + slurp: + src: "{{ output_file }}" + register: content_type_15 + +- name: test-type 15 - verify multiline string uses triple quotes + assert: + that: + - "'description = \"\"\"' in (content_type_15.content | b64decode)" + +- name: test-type 16 - set multiline literal string + toml_file: + path: "{{ output_file }}" + table: types + key: raw_block + value: "Raw\ntext\nblock" + value_type: multiline_literal_string + register: result_type_16 + +- name: test-type 16 - verify change + assert: + that: + - result_type_16 is changed + +- name: test-type 16 - read content + slurp: + src: "{{ output_file }}" + register: content_type_16 + +- name: test-type 16 - verify multiline literal string uses triple single quotes + assert: + that: + - "\"raw_block = '''\" in (content_type_16.content | b64decode)" + +## Group 6: Reset and test integer variants + +- name: Reset output file for group 6 + file: + path: "{{ output_file }}" + state: absent + +- name: test-type 17 - set hexadecimal integer + toml_file: + path: "{{ output_file }}" + table: types + key: hex_color + value: 16777215 + value_type: hex_integer + register: result_type_17 + +- name: test-type 17 - verify change + assert: + that: + - result_type_17 is changed + +- name: test-type 17 - read content + slurp: + src: "{{ output_file }}" + register: content_type_17 + +- name: test-type 17 - verify hex integer in file + vars: + expected: | + [types] + hex_color = 0xffffff + assert: + that: + - (content_type_17.content | b64decode) == expected + +- name: test-type 18 - set octal integer + toml_file: + path: "{{ output_file }}" + table: types + key: file_mode + value: 493 + value_type: octal_integer + register: result_type_18 + +- name: test-type 18 - verify change + assert: + that: + - result_type_18 is changed + +- name: test-type 18 - read content + slurp: + src: "{{ output_file }}" + register: content_type_18 + +- name: test-type 18 - verify octal integer in file + vars: + expected: | + [types] + hex_color = 0xffffff + file_mode = 0o755 + assert: + that: + - (content_type_18.content | b64decode) == expected + +- name: test-type 19 - set binary integer + toml_file: + path: "{{ output_file }}" + table: types + key: flags + value: 170 + value_type: binary_integer + register: result_type_19 + +- name: test-type 19 - verify change + assert: + that: + - result_type_19 is changed + +- name: test-type 19 - read content + slurp: + src: "{{ output_file }}" + register: content_type_19 + +- name: test-type 19 - verify binary integer in file + vars: + expected: | + [types] + hex_color = 0xffffff + file_mode = 0o755 + flags = 0b10101010 + assert: + that: + - (content_type_19.content | b64decode) == expected diff --git a/tests/integration/targets/toml_file/tasks/06-comments.yml b/tests/integration/targets/toml_file/tasks/06-comments.yml new file mode 100644 index 0000000000..cea235ecad --- /dev/null +++ b/tests/integration/targets/toml_file/tasks/06-comments.yml @@ -0,0 +1,127 @@ +--- +# 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 + +## Comment preservation tests + +- name: test-comment 1 - create a TOML file with comments + copy: + dest: "{{ output_file }}" + content: | + # This is a file-level comment + title = "My App" + + # Server configuration + [server] + # The host to bind to + host = "localhost" + # The port to listen on + port = 8080 + +- name: test-comment 2 - modify a value (should preserve comments) + toml_file: + path: "{{ output_file }}" + table: server + key: port + value: 9090 + register: result_comment_2 + +- name: test-comment 2 - verify change + assert: + that: + - result_comment_2 is changed + +- name: test-comment 2 - read content + slurp: + src: "{{ output_file }}" + register: content_comment_2 + +- name: test-comment 2 - verify comments preserved + vars: + expected: | + # This is a file-level comment + title = "My App" + + # Server configuration + [server] + # The host to bind to + host = "localhost" + # The port to listen on + port = 9090 + assert: + that: + - (content_comment_2.content | b64decode) == expected + +- name: test-comment 3 - add a new key (should preserve existing comments) + toml_file: + path: "{{ output_file }}" + table: server + key: timeout + value: 30 + register: result_comment_3 + +- name: test-comment 3 - verify change + assert: + that: + - result_comment_3 is changed + +- name: test-comment 3 - read content + slurp: + src: "{{ output_file }}" + register: content_comment_3 + +- name: test-comment 3 - verify comments still preserved + vars: + expected: | + # This is a file-level comment + title = "My App" + + # Server configuration + [server] + # The host to bind to + host = "localhost" + # The port to listen on + port = 9090 + timeout = 30 + assert: + that: + - (content_comment_3.content | b64decode) == expected + +- name: test-comment 4 - add a new table (should preserve existing comments) + toml_file: + path: "{{ output_file }}" + table: database + key: host + value: db.example.com + register: result_comment_4 + +- name: test-comment 4 - verify change + assert: + that: + - result_comment_4 is changed + +- name: test-comment 4 - read content + slurp: + src: "{{ output_file }}" + register: content_comment_4 + +- name: test-comment 4 - verify original structure preserved + vars: + expected: | + # This is a file-level comment + title = "My App" + + # Server configuration + [server] + # The host to bind to + host = "localhost" + # The port to listen on + port = 9090 + timeout = 30 + + [database] + host = "db.example.com" + assert: + that: + - (content_comment_4.content | b64decode) == expected diff --git a/tests/integration/targets/toml_file/tasks/07-backup.yml b/tests/integration/targets/toml_file/tasks/07-backup.yml new file mode 100644 index 0000000000..140034e93d --- /dev/null +++ b/tests/integration/targets/toml_file/tasks/07-backup.yml @@ -0,0 +1,108 @@ +--- +# 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 + +## Backup tests + +- name: test-backup 1 - create initial file + toml_file: + path: "{{ output_file }}" + table: server + key: host + value: localhost + register: result_backup_1 + +- name: test-backup 1 - verify no backup file returned + assert: + that: + - result_backup_1 is changed + - "'backup_file' not in result_backup_1" + +- name: test-backup 1 - read content + slurp: + src: "{{ output_file }}" + register: content_backup_1 + +- name: test-backup 1 - verify initial content + vars: + expected: | + [server] + host = "localhost" + assert: + that: + - (content_backup_1.content | b64decode) == expected + +- name: test-backup 2 - modify with backup enabled + toml_file: + path: "{{ output_file }}" + table: server + key: host + value: "192.168.1.1" + backup: true + register: result_backup_2 + +- name: test-backup 2 - verify backup file created + assert: + that: + - result_backup_2 is changed + - "'backup_file' in result_backup_2" + - result_backup_2.backup_file is match(".*test.toml.*~$") + +- name: test-backup 2 - verify backup file exists + stat: + path: "{{ result_backup_2.backup_file }}" + register: stat_backup_2 + +- name: test-backup 2 - assert backup file exists + assert: + that: + - stat_backup_2.stat.exists + +- name: test-backup 2 - read backup file content + slurp: + src: "{{ result_backup_2.backup_file }}" + register: content_backup_2 + +- name: test-backup 2 - verify backup has original content + vars: + expected: | + [server] + host = "localhost" + assert: + that: + - (content_backup_2.content | b64decode) == expected + +- name: test-backup 2 - read current file content + slurp: + src: "{{ output_file }}" + register: content_backup_2_current + +- name: test-backup 2 - verify current file has new content + vars: + expected: | + [server] + host = "192.168.1.1" + assert: + that: + - (content_backup_2_current.content | b64decode) == expected + +- name: test-backup 3 - no change should not create backup + toml_file: + path: "{{ output_file }}" + table: server + key: host + value: "192.168.1.1" + backup: true + register: result_backup_3 + +- name: test-backup 3 - verify no backup when no change + assert: + that: + - result_backup_3 is not changed + - "'backup_file' not in result_backup_3 or result_backup_3.backup_file is none" + +- name: test-backup 4 - cleanup backup file + file: + path: "{{ result_backup_2.backup_file }}" + state: absent diff --git a/tests/integration/targets/toml_file/tasks/main.yml b/tests/integration/targets/toml_file/tasks/main.yml new file mode 100644 index 0000000000..40c9b997c4 --- /dev/null +++ b/tests/integration/targets/toml_file/tasks/main.yml @@ -0,0 +1,81 @@ +--- +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +# test code for toml_file module +# Copyright (c) 2026, Jose Drowne +# 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 tomlkit + pip: + name: tomlkit + state: present + +- name: Record the output directory + set_fact: + output_file: "{{ remote_tmp_dir }}/test.toml" + non_existing_file: "{{ remote_tmp_dir }}/nonexistent.toml" + +- name: Include tasks + block: + - name: Include tasks to perform basic tests + include_tasks: 00-basic.yml + + - name: Reset output file + file: + path: "{{ output_file }}" + state: absent + + - name: Include tasks to perform table tests + include_tasks: 01-tables.yml + + - name: Reset output file + file: + path: "{{ output_file }}" + state: absent + + - name: Include tasks to perform nested tests + include_tasks: 02-nested.yml + + - name: Reset output file + file: + path: "{{ output_file }}" + state: absent + + - name: Include tasks to perform array tests + include_tasks: 03-arrays.yml + + - name: Reset output file + file: + path: "{{ output_file }}" + state: absent + + - name: Include tasks to perform array of tables tests + include_tasks: 04-array-tables.yml + + - name: Reset output file + file: + path: "{{ output_file }}" + state: absent + + - name: Include tasks to perform type tests + include_tasks: 05-types.yml + + - name: Reset output file + file: + path: "{{ output_file }}" + state: absent + + - name: Include tasks to perform comment preservation tests + include_tasks: 06-comments.yml + + - name: Reset output file + file: + path: "{{ output_file }}" + state: absent + + - name: Include tasks to perform backup tests + include_tasks: 07-backup.yml From 0f1190aedd3cbc9c67499fcb82ca80d13b728e6a Mon Sep 17 00:00:00 2001 From: Jose Drowne Date: Tue, 20 Jan 2026 22:08:00 -0500 Subject: [PATCH 02/17] Fix expected values for trailing blank line after table removal tomlkit preserves the blank line that preceded a removed table, so the expected test values need to account for this trailing blank line. Co-Authored-By: Claude Opus 4.5 --- tests/integration/targets/toml_file/tasks/01-tables.yml | 2 ++ tests/integration/targets/toml_file/tasks/04-array-tables.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/tests/integration/targets/toml_file/tasks/01-tables.yml b/tests/integration/targets/toml_file/tasks/01-tables.yml index 32c3847b23..9a82d75b6d 100644 --- a/tests/integration/targets/toml_file/tasks/01-tables.yml +++ b/tests/integration/targets/toml_file/tasks/01-tables.yml @@ -111,10 +111,12 @@ - name: test-table 4 - verify table removed vars: + # Note: tomlkit preserves the blank line that preceded the removed table expected: | [database] host = "localhost" port = 5432 + assert: that: - (content_table_4.content | b64decode) == expected diff --git a/tests/integration/targets/toml_file/tasks/04-array-tables.yml b/tests/integration/targets/toml_file/tasks/04-array-tables.yml index 5b4b9363ce..9e4abadf3e 100644 --- a/tests/integration/targets/toml_file/tasks/04-array-tables.yml +++ b/tests/integration/targets/toml_file/tasks/04-array-tables.yml @@ -179,9 +179,11 @@ - name: test-aot 6 - verify last entry removed vars: + # Note: tomlkit preserves the blank line that preceded the removed entry expected: | [[products]] name = "Hammer" + assert: that: - (content_aot_6.content | b64decode) == expected From 2a021e480fa7e4e82a21afc85743593f29aacde9 Mon Sep 17 00:00:00 2001 From: Jose Drowne Date: Thu, 22 Jan 2026 23:22:03 -0500 Subject: [PATCH 03/17] Refactor toml_file module based on maintainer feedback - Merge array_table_index into table parameter using bracket notation (e.g., products[0], products[-1], servers[1].databases[0]) - Update documentation: choices as dictionary, version_added, return values - Remove unhelpful Any type hints - Code style improvements: guard clauses, remove elif after return, check conditions before parsing, simplify value comparisons - Use exception-based error handling instead of magic string detection Co-Authored-By: Claude Opus 4.5 --- plugins/modules/toml_file.py | 515 +++++++++--------- .../toml_file/tasks/04-array-tables.yml | 18 +- 2 files changed, 264 insertions(+), 269 deletions(-) diff --git a/plugins/modules/toml_file.py b/plugins/modules/toml_file.py index c52c7e3cc0..a6f42be026 100644 --- a/plugins/modules/toml_file.py +++ b/plugins/modules/toml_file.py @@ -9,6 +9,7 @@ from __future__ import annotations DOCUMENTATION = r""" module: toml_file short_description: Manage individual settings in TOML files +version_added: 12.3.0 extends_documentation_fragment: - ansible.builtin.files - community.general.attributes @@ -25,7 +26,7 @@ attributes: options: path: description: - - Path to the TOML file; this file is created if required. + - Path to the TOML file; this file is created if required and O(create=true). type: path required: true aliases: [dest] @@ -33,6 +34,9 @@ options: description: - Table name in the TOML file. - Use dotted notation for nested tables (for example, V(server.database)). + - For array of tables (C([[table]])), use bracket notation to specify the index + (for example, V(products[0]) for the first entry, V(products[-1]) to append a new entry). + - Nested arrays of tables are supported (for example, V(servers[1].databases[0])). - If omitted, the key is set at the document root level. type: str key: @@ -44,47 +48,31 @@ options: value: description: - The value to set for the key. - - YAML types are automatically converted to appropriate TOML types. + - JSON types are automatically converted to appropriate TOML types. - Use O(value_type) to force a specific type. - Required when O(state=present) and O(key) is specified. type: raw value_type: description: - Force the value to be a specific TOML type. - - V(auto) automatically detects the type from the input value. - - V(string) forces the value to be a TOML string. - - V(literal_string) creates a TOML literal string (single quotes, no escape processing). - - V(multiline_string) creates a TOML multi-line basic string (triple double quotes). - - V(multiline_literal_string) creates a TOML multi-line literal string (triple single quotes). - - V(integer) forces the value to be a TOML integer. - - V(hex_integer) forces the value to be a TOML hexadecimal integer (for example, V(0xDEADBEEF)). - - V(octal_integer) forces the value to be a TOML octal integer (for example, V(0o755)). - - V(binary_integer) forces the value to be a TOML binary integer (for example, V(0b11010110)). - - V(float) forces the value to be a TOML float. Supports V(inf), V(-inf), and V(nan). - - V(boolean) forces the value to be a TOML boolean. - - V(datetime) parses the value as an ISO 8601 offset or local datetime. - - V(date) parses the value as a local date (for example, V(1979-05-27)). - - V(time) parses the value as a local time (for example, V(07:32:00)). - - V(array) ensures the value is a TOML array. - - V(inline_table) ensures the value is a TOML inline table. type: str choices: - - auto - - string - - literal_string - - multiline_string - - multiline_literal_string - - integer - - hex_integer - - octal_integer - - binary_integer - - float - - boolean - - datetime - - date - - time - - array - - inline_table + auto: Automatically detects the type from the input value. + string: Forces the value to be a TOML string. + literal_string: Creates a TOML literal string (single quotes, no escape processing). + multiline_string: Creates a TOML multi-line basic string (triple double quotes). + multiline_literal_string: Creates a TOML multi-line literal string (triple single quotes). + integer: Forces the value to be a TOML integer. + hex_integer: Forces the value to be a TOML hexadecimal integer (for example, V(0xDEADBEEF)). + octal_integer: Forces the value to be a TOML octal integer (for example, V(0o755)). + binary_integer: Forces the value to be a TOML binary integer (for example, V(0b11010110)). + float: Forces the value to be a TOML float. Supports V(inf), V(-inf), and V(nan). + boolean: Forces the value to be a TOML boolean. + datetime: Parses the value as an ISO 8601 offset or local datetime. + date: Parses the value as a local date (for example, V(1979-05-27)). + time: Parses the value as a local time (for example, V(07:32:00)). + array: Ensures the value is a TOML array. + inline_table: Ensures the value is a TOML inline table. default: auto state: description: @@ -111,13 +99,6 @@ options: - O(follow=true) can modify O(path) when combined with parameters such as O(mode). type: bool default: false - array_table_index: - description: - - For array of tables (C([[table]])), specify which entry to modify. - - Use V(-1) to append a new entry to the array of tables. - - Use V(0) for the first entry, V(1) for the second, and so on. - - If the table is not an array of tables, this parameter is ignored. - type: int requirements: - tomlkit notes: @@ -185,18 +166,23 @@ EXAMPLES = r""" - name: Add entry to array of tables community.general.toml_file: path: /etc/myapp/config.toml - table: products + table: products[-1] key: name value: Hammer - array_table_index: -1 - name: Modify first entry in array of tables community.general.toml_file: path: /etc/myapp/config.toml - table: products + table: products[0] key: price value: 9.99 - array_table_index: 0 + +- name: Modify nested array of tables + community.general.toml_file: + path: /etc/myapp/config.toml + table: servers[0].databases[1] + key: name + value: secondary_db - name: Set an inline table community.general.toml_file: @@ -286,7 +272,7 @@ EXAMPLES = r""" RETURN = r""" path: description: The path to the TOML file. - returned: always + returned: success type: str sample: /etc/myapp/config.toml msg: @@ -296,12 +282,12 @@ msg: sample: key added backup_file: description: The name of the backup file that was created. - returned: when backup=true and file was modified + returned: when O(backup=true) and file was modified type: str sample: /etc/myapp/config.toml.2026-01-17@12:34:56~ diff: description: The differences between the old and new file. - returned: when diff mode is enabled + returned: success and diff mode is enabled type: dict contains: before: @@ -313,10 +299,10 @@ diff: """ import os +import re import tempfile import traceback from datetime import datetime -from typing import Any from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.common.text.converters import to_bytes @@ -332,7 +318,42 @@ class TomlFileError(Exception): pass -def load_toml_document(path: str, create: bool = True) -> Any: +def parse_table_path(table_path: str | None) -> list[tuple[str, int | None]]: + """Parse a table path with optional array indices. + + Examples: + "products" -> [("products", None)] + "products[0]" -> [("products", 0)] + "products[-1]" -> [("products", -1)] + "servers[1].databases[0]" -> [("servers", 1), ("databases", 0)] + "server.database" -> [("server", None), ("database", None)] + """ + if not table_path: + return [] + + result = [] + segment_pattern = re.compile(r"^([^\[\]]+)(?:\[(-?\d+)\])?$") + + for segment in table_path.split("."): + match = segment_pattern.match(segment) + if not match: + raise TomlFileError(f"Invalid table path segment: '{segment}'") + name = match.group(1) + index_str = match.group(2) + index = int(index_str) if index_str is not None else None + result.append((name, index)) + + return result + + +def _format_path(parsed: list[tuple[str, int | None]], up_to: int | None = None) -> str: + """Format a parsed path back to string for error messages.""" + if up_to is None: + up_to = len(parsed) + return ".".join(name + (f"[{idx}]" if idx is not None else "") for name, idx in parsed[:up_to]) + + +def load_toml_document(path, create=True): if not os.path.exists(path): if not create: raise TomlFileError(f"Destination {path} does not exist!") @@ -345,133 +366,140 @@ def load_toml_document(path: str, create: bool = True) -> Any: raise TomlFileError(f"Failed to parse TOML file: {e}") from e -def navigate_to_table( - doc: Any, table_path: str | None, create: bool = False, array_table_index: int | None = None -) -> Any: - if not table_path: +def navigate_to_table(doc, table_path, create=False): + """Navigate to a table in the document. + + The table_path can include array indices using bracket notation: + - "products[0]" - first entry in products array of tables + - "products[-1]" - last entry (or append new if create=True) + - "servers[1].databases[0]" - nested arrays + """ + parsed = parse_table_path(table_path) + if not parsed: return doc - parts = table_path.split(".") current = doc - for i, part in enumerate(parts): - is_last = i == len(parts) - 1 + for i, (name, index) in enumerate(parsed): + is_last = i == len(parsed) - 1 - if part not in current: + if name not in current: if not create: - raise TomlFileError(f"Table '{'.'.join(parts[: i + 1])}' does not exist") + raise TomlFileError(f"Table '{_format_path(parsed, i + 1)}' does not exist") - if is_last and array_table_index == -1: + if index == -1: new_table = tomlkit.table() aot = tomlkit.aot() aot.append(new_table) - current[part] = aot + current[name] = aot current = new_table + elif index is not None: + raise TomlFileError(f"Cannot create table at index {index} - array of tables '{name}' does not exist") else: new_table = tomlkit.table() - current[part] = new_table + current[name] = new_table current = new_table else: - item = current[part] + item = current[name] if isinstance(item, AoT): - if array_table_index is not None: - if array_table_index == -1: - if is_last: + if index is not None: + if index == -1: + if is_last and create: new_table = tomlkit.table() item.append(new_table) current = new_table else: if len(item) == 0: - raise TomlFileError(f"Array of tables '{'.'.join(parts[: i + 1])}' is empty") + raise TomlFileError(f"Array of tables '{_format_path(parsed, i + 1)}' is empty") current = item[-1] - elif array_table_index < len(item): - current = item[array_table_index] + elif 0 <= index < len(item): + current = item[index] else: raise TomlFileError( - f"Array of tables index {array_table_index} is out of range for '{'.'.join(parts[: i + 1])}'" + f"Array of tables index {index} is out of range for '{_format_path(parsed, i + 1)}'" ) else: if len(item) == 0: - raise TomlFileError(f"Array of tables '{'.'.join(parts[: i + 1])}' is empty") + raise TomlFileError(f"Array of tables '{_format_path(parsed, i + 1)}' is empty") current = item[0] elif isinstance(item, (Table, dict)): + if index is not None: + raise TomlFileError(f"'{_format_path(parsed, i + 1)}' is not an array of tables, cannot use index") current = item else: - raise TomlFileError(f"'{'.'.join(parts[: i + 1])}' is not a table") + raise TomlFileError(f"'{_format_path(parsed, i + 1)}' is not a table") return current -def convert_value(value: Any, value_type: str = "auto") -> Any: +def convert_value(value, value_type="auto"): if value_type == "auto": if isinstance(value, (bool, int, float)): return value - elif isinstance(value, str): - try: - dt = datetime.fromisoformat(value.replace("Z", "+00:00")) - if "T" in value or " " in value: - return dt - except (ValueError, AttributeError): - pass + if isinstance(value, str): + if "T" in value or " " in value: + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except (ValueError, AttributeError): + pass return value - elif isinstance(value, list): + if isinstance(value, list): arr = tomlkit.array() for item in value: arr.append(convert_value(item, "auto")) return arr - elif isinstance(value, dict): + if isinstance(value, dict): tbl = tomlkit.inline_table() for k, v in value.items(): tbl[k] = convert_value(v, "auto") return tbl - else: - return str(value) - elif value_type == "string": return str(value) - elif value_type == "literal_string": + if value_type == "string": + return str(value) + if value_type == "literal_string": return tomlkit.string(str(value), literal=True) - elif value_type == "multiline_string": + if value_type == "multiline_string": return tomlkit.string(str(value), multiline=True) - elif value_type == "multiline_literal_string": + if value_type == "multiline_literal_string": return tomlkit.string(str(value), literal=True, multiline=True) - elif value_type == "integer": + if value_type == "integer": return int(value) - elif value_type == "hex_integer": + if value_type == "hex_integer": int_val = int(value) if isinstance(value, int) else int(str(value), 16) return Integer(int_val, Trivia(), hex(int_val)) - elif value_type == "octal_integer": + if value_type == "octal_integer": int_val = int(value) if isinstance(value, int) else int(str(value), 8) return Integer(int_val, Trivia(), oct(int_val)) - elif value_type == "binary_integer": + if value_type == "binary_integer": int_val = int(value) if isinstance(value, int) else int(str(value), 2) return Integer(int_val, Trivia(), bin(int_val)) - elif value_type == "float": + if value_type == "float": if isinstance(value, str): return float(value.lower()) return float(value) - elif value_type == "boolean": + if value_type == "boolean": if isinstance(value, bool): return value if isinstance(value, str): return value.lower() in ("true", "yes", "1", "on") return bool(value) - elif value_type == "datetime": + if value_type == "datetime": if isinstance(value, datetime): return value return datetime.fromisoformat(str(value).replace("Z", "+00:00")) - elif value_type == "date": + if value_type == "date": from datetime import date as date_type if isinstance(value, date_type) and not isinstance(value, datetime): return tomlkit.date(value.isoformat()) return tomlkit.date(str(value)) - elif value_type == "time": + if value_type == "time": from datetime import time as time_type if isinstance(value, time_type): return tomlkit.time(value.isoformat()) return tomlkit.time(str(value)) - elif value_type == "array": + if value_type == "array": if isinstance(value, list): arr = tomlkit.array() for item in value: @@ -480,18 +508,17 @@ def convert_value(value: Any, value_type: str = "auto") -> Any: arr = tomlkit.array() arr.append(convert_value(value, "auto")) return arr - elif value_type == "inline_table": - if isinstance(value, dict): - tbl = tomlkit.inline_table() - for k, v in value.items(): - tbl[k] = convert_value(v, "auto") - return tbl - raise ValueError(f"Cannot convert {type(value).__name__} to inline_table") - else: - return value + if value_type == "inline_table": + if not isinstance(value, dict): + raise ValueError(f"Cannot convert {type(value).__name__} to inline_table") + tbl = tomlkit.inline_table() + for k, v in value.items(): + tbl[k] = convert_value(v, "auto") + return tbl + return value -def navigate_to_key_parent(target: Any, key: str) -> tuple[Any, str]: +def navigate_to_key_parent(target, key): parts = key.split(".") if len(parts) == 1: return target, key @@ -512,7 +539,7 @@ def navigate_to_key_parent(target: Any, key: str) -> tuple[Any, str]: return parent, parts[-1] -def set_key_value(target: Any, key: str, value: Any, value_type: str = "auto") -> bool: +def set_key_value(target, key, value, value_type="auto"): parent, final_key = navigate_to_key_parent(target, key) converted_value = convert_value(value, value_type) @@ -525,27 +552,19 @@ def set_key_value(target: Any, key: str, value: Any, value_type: str = "auto") - return True -def _values_equal(a: Any, b: Any) -> bool: +def _values_equal(a, b): if isinstance(a, Array) and isinstance(b, (Array, list)): - a_list = list(a) - b_list = list(b) if isinstance(b, Array) else b - if len(a_list) != len(b_list): + if len(a) != len(b): return False - return all(_values_equal(x, y) for x, y in zip(a_list, b_list)) - elif isinstance(a, (InlineTable, Table)) and isinstance(b, (InlineTable, Table, dict)): - a_dict = dict(a) - b_dict = dict(b) if isinstance(b, (InlineTable, Table)) else b - if set(a_dict.keys()) != set(b_dict.keys()): - return False - return all(_values_equal(a_dict[k], b_dict[k]) for k in a_dict) - else: - try: - return a == b - except Exception: + return all(_values_equal(x, y) for x, y in zip(a, b)) + if isinstance(a, (InlineTable, Table)) and isinstance(b, (InlineTable, Table, dict)): + if a.keys() != b.keys(): return False + return all(_values_equal(a[k], b[k]) for k in a) + return a == b -def remove_key(target: Any, key: str) -> bool: +def remove_key(target, key): parts = key.split(".") parent = target for part in parts[:-1]: @@ -564,64 +583,62 @@ def remove_key(target: Any, key: str) -> bool: return False -def remove_table(doc: Any, table_path: str | None, array_table_index: int | None = None) -> bool: - if not table_path: +def remove_table(doc, table_path): + """Remove a table from the document. + + The table_path can include array indices using bracket notation: + - "products[0]" - remove first entry from products array of tables + - "products[-1]" - remove last entry + - "products" - remove entire array of tables + """ + parsed = parse_table_path(table_path) + if not parsed: raise TomlFileError("Cannot remove document root") - parts = table_path.split(".") - if len(parts) == 1: - if parts[0] in doc: - item = doc[parts[0]] - if isinstance(item, AoT) and array_table_index is not None: - if array_table_index == -1: - if len(item) > 0: - del item[-1] - return True - return False - elif 0 <= array_table_index < len(item): - del item[array_table_index] - return True - raise TomlFileError(f"Array of tables index {array_table_index} out of range") - del doc[parts[0]] - return True + # Navigate to parent of the target + if len(parsed) == 1: + parent = doc + else: + try: + parent_path = _format_path(parsed[:-1]) + parent = navigate_to_table(doc, parent_path, create=False) + except TomlFileError: + return False + + name, index = parsed[-1] + + if name not in parent: return False - try: - parent = navigate_to_table(doc, ".".join(parts[:-1]), create=False) - except TomlFileError: - return False - - final_part = parts[-1] - if final_part in parent: - item = parent[final_part] - if isinstance(item, AoT) and array_table_index is not None: - if array_table_index == -1: - if len(item) > 0: - del item[-1] - return True - return False - elif 0 <= array_table_index < len(item): - del item[array_table_index] + item = parent[name] + if isinstance(item, AoT) and index is not None: + if index == -1: + if len(item) > 0: + del item[-1] return True - raise TomlFileError(f"Array of tables index {array_table_index} out of range") - del parent[final_part] - return True - return False + return False + elif 0 <= index < len(item): + del item[index] + return True + raise TomlFileError(f"Array of tables index {index} out of range") + + # If it's an AoT but no index specified, or a regular table, remove entirely + del parent[name] + return True def do_toml( - module: AnsibleModule, - path: str, - table: str | None, - key: str | None, - value: Any, - value_type: str, - state: str, - backup: bool, - create: bool, - follow: bool, - array_table_index: int | None, -) -> tuple[bool, str | None, dict[str, str], str]: + module, + path, + table, + key, + value, + value_type, + state, + backup, + create, + follow, +): diff = { "before": "", "after": "", @@ -634,84 +651,80 @@ def do_toml( else: target_path = path - try: - doc = load_toml_document(target_path, create) + doc = load_toml_document(target_path, create) - if module._diff: - diff["before"] = tomlkit.dumps(doc) + if module._diff: + diff["before"] = tomlkit.dumps(doc) - original_content = tomlkit.dumps(doc) - changed = False - msg = "OK" + original_content = tomlkit.dumps(doc) + changed = False + msg = "OK" - if state == "present": - if key: - if value is None: - raise TomlFileError("Parameter 'value' is required when state=present and key is specified") + if state == "present": + if key: + if value is None: + raise TomlFileError("Parameter 'value' is required when state=present and key is specified") - target = navigate_to_table(doc, table, create=True, array_table_index=array_table_index) - changed = set_key_value(target, key, value, value_type) + target = navigate_to_table(doc, table, create=True) + changed = set_key_value(target, key, value, value_type) - if changed: - msg = "key added" if key not in target or changed else "key changed" + if changed: + msg = "key added" if key not in target or changed else "key changed" + else: + if table: + navigate_to_table(doc, table, create=True) + new_content = tomlkit.dumps(doc) + if new_content != original_content: + changed = True + msg = "table added" else: - if table: - navigate_to_table(doc, table, create=True, array_table_index=array_table_index) - new_content = tomlkit.dumps(doc) - if new_content != original_content: - changed = True - msg = "table added" - else: - msg = "nothing to do" - elif state == "absent": - if key: - try: - target = navigate_to_table(doc, table, create=False, array_table_index=array_table_index) - changed = remove_key(target, key) - msg = "key removed" if changed else "key not found" - except TomlFileError: - msg = "key not found" + msg = "nothing to do" + elif state == "absent": + if key: + try: + target = navigate_to_table(doc, table, create=False) + changed = remove_key(target, key) + msg = "key removed" if changed else "key not found" + except TomlFileError: + msg = "key not found" + else: + if table: + changed = remove_table(doc, table) + msg = "table removed" if changed else "table not found" else: - if table: - changed = remove_table(doc, table, array_table_index) - msg = "table removed" if changed else "table not found" - else: - msg = "nothing to do" + msg = "nothing to do" - if module._diff: - diff["after"] = tomlkit.dumps(doc) + if module._diff: + diff["after"] = tomlkit.dumps(doc) - backup_file = None - if changed and not module.check_mode: - if backup and os.path.exists(target_path): - backup_file = module.backup_local(target_path) + backup_file = None + if changed and not module.check_mode: + if backup and os.path.exists(target_path): + backup_file = module.backup_local(target_path) - destpath = os.path.dirname(target_path) - if destpath and not os.path.exists(destpath): - os.makedirs(destpath) + destpath = os.path.dirname(target_path) + if destpath and not os.path.exists(destpath): + os.makedirs(destpath) - content = tomlkit.dumps(doc) - encoded_content = to_bytes(content) + content = tomlkit.dumps(doc) + encoded_content = to_bytes(content) - try: - tmpfd, tmpfile = tempfile.mkstemp(dir=module.tmpdir) - with os.fdopen(tmpfd, "wb") as f: - f.write(encoded_content) - except OSError: - module.fail_json(msg="Unable to create temporary file", traceback=traceback.format_exc()) + try: + tmpfd, tmpfile = tempfile.mkstemp(dir=module.tmpdir) + with os.fdopen(tmpfd, "wb") as f: + f.write(encoded_content) + except OSError: + module.fail_json(msg="Unable to create temporary file", traceback=traceback.format_exc()) - try: - module.atomic_move(tmpfile, os.path.abspath(target_path)) - except OSError: - module.fail_json( - msg=f"Unable to move temporary file {tmpfile} to {target_path}", - traceback=traceback.format_exc(), - ) + try: + module.atomic_move(tmpfile, os.path.abspath(target_path)) + except OSError: + module.fail_json( + msg=f"Unable to move temporary file {tmpfile} to {target_path}", + traceback=traceback.format_exc(), + ) - return changed, backup_file, diff, msg - - except TomlFileError as e: - return False, None, diff, str(e) + return changed, backup_file, diff, msg def main() -> None: @@ -747,7 +760,6 @@ def main() -> None: backup=dict(type="bool", default=False), create=dict(type="bool", default=True), follow=dict(type="bool", default=False), - array_table_index=dict(type="int"), ), add_file_common_args=True, supports_check_mode=True, @@ -764,24 +776,13 @@ def main() -> None: backup = module.params["backup"] create = module.params["create"] follow = module.params["follow"] - array_table_index = module.params["array_table_index"] - changed, backup_file, diff, msg = do_toml( - module, path, table, key, value, value_type, state, backup, create, follow, array_table_index - ) - - if msg and msg not in [ - "OK", - "key added", - "key changed", - "key removed", - "key not found", - "table added", - "table removed", - "table not found", - "nothing to do", - ]: - module.fail_json(msg=msg) + try: + changed, backup_file, diff, msg = do_toml( + module, path, table, key, value, value_type, state, backup, create, follow + ) + except TomlFileError as e: + module.fail_json(msg=str(e)) if not module.check_mode and os.path.exists(path): file_args = module.load_file_common_arguments(module.params) diff --git a/tests/integration/targets/toml_file/tasks/04-array-tables.yml b/tests/integration/targets/toml_file/tasks/04-array-tables.yml index 9e4abadf3e..a787ff378a 100644 --- a/tests/integration/targets/toml_file/tasks/04-array-tables.yml +++ b/tests/integration/targets/toml_file/tasks/04-array-tables.yml @@ -8,10 +8,9 @@ - name: test-aot 1 - create first array of tables entry toml_file: path: "{{ output_file }}" - table: products + table: products[-1] key: name value: Hammer - array_table_index: -1 register: result_aot_1 - name: test-aot 1 - verify change @@ -36,10 +35,9 @@ - name: test-aot 2 - add key to existing entry toml_file: path: "{{ output_file }}" - table: products + table: products[0] key: price value: 9.99 - array_table_index: 0 register: result_aot_2 - name: test-aot 2 - verify change @@ -65,10 +63,9 @@ - name: test-aot 3 - append new entry to array of tables toml_file: path: "{{ output_file }}" - table: products + table: products[-1] key: name value: Nail - array_table_index: -1 register: result_aot_3 - name: test-aot 3 - verify change @@ -97,10 +94,9 @@ - name: test-aot 4 - modify second entry toml_file: path: "{{ output_file }}" - table: products + table: products[1] key: price value: 0.05 - array_table_index: 1 register: result_aot_4 - name: test-aot 4 - verify change @@ -130,10 +126,9 @@ - name: test-aot 5 - remove key from first entry toml_file: path: "{{ output_file }}" - table: products + table: products[0] key: price state: absent - array_table_index: 0 register: result_aot_5 - name: test-aot 5 - verify change @@ -162,9 +157,8 @@ - name: test-aot 6 - remove last entry from array of tables toml_file: path: "{{ output_file }}" - table: products + table: products[-1] state: absent - array_table_index: -1 register: result_aot_6 - name: test-aot 6 - verify change From 8ab12b223d2dd99d4d5fa493b369752e869a5807 Mon Sep 17 00:00:00 2001 From: Jose Drowne Date: Fri, 23 Jan 2026 00:19:09 -0500 Subject: [PATCH 04/17] Add blank line before new array of tables entries When appending a new entry to an existing array of tables (AoT), tomlkit doesn't automatically add a blank line separator between entries. This fix adds the trivia indent to ensure proper TOML formatting with blank lines between [[table]] entries. Fixes CI failures in test-aot 3-6. Co-Authored-By: Claude Opus 4.5 --- plugins/modules/toml_file.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/modules/toml_file.py b/plugins/modules/toml_file.py index a6f42be026..2ab607dfb2 100644 --- a/plugins/modules/toml_file.py +++ b/plugins/modules/toml_file.py @@ -406,6 +406,7 @@ def navigate_to_table(doc, table_path, create=False): if index == -1: if is_last and create: new_table = tomlkit.table() + new_table.trivia.indent = '\n' item.append(new_table) current = new_table else: From 298e95ee02da7cbf834f9e2cbecb9175c26e67b1 Mon Sep 17 00:00:00 2001 From: Jose Drowne Date: Fri, 23 Jan 2026 08:30:21 -0500 Subject: [PATCH 05/17] Refactor toml_file module for reduced complexity - Add TomlParams dataclass to reduce do_toml() from 10 to 2 arguments - Add NavContext dataclass to reduce navigation helper arguments - Extract 19 type-specific converter functions with dispatch dict for O(1) lookup - Extract 8 table navigation helper functions to reduce nesting depth - Extract 4 do_toml helper functions (_init_diff, _handle_present, etc.) - Add len() early exit optimization in _values_equal() All functions now meet constraints: - Maximum arguments: 5 - Maximum nesting depth: 3 - Maximum statements: 50 Co-Authored-By: Claude Opus 4.5 --- plugins/modules/toml_file.py | 700 +++++++++++++++++++++-------------- 1 file changed, 423 insertions(+), 277 deletions(-) diff --git a/plugins/modules/toml_file.py b/plugins/modules/toml_file.py index 2ab607dfb2..a9f7956f85 100644 --- a/plugins/modules/toml_file.py +++ b/plugins/modules/toml_file.py @@ -302,7 +302,9 @@ import os import re import tempfile import traceback +from dataclasses import dataclass from datetime import datetime +from typing import Any from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.common.text.converters import to_bytes @@ -318,16 +320,36 @@ class TomlFileError(Exception): pass -def parse_table_path(table_path: str | None) -> list[tuple[str, int | None]]: - """Parse a table path with optional array indices. +@dataclass +class TomlParams: + """Parameters for TOML file operations.""" - Examples: - "products" -> [("products", None)] - "products[0]" -> [("products", 0)] - "products[-1]" -> [("products", -1)] - "servers[1].databases[0]" -> [("servers", 1), ("databases", 0)] - "server.database" -> [("server", None), ("database", None)] - """ + path: str + table: str | None + key: str | None + value: Any + value_type: str + state: str + backup: bool + create: bool + follow: bool + + +@dataclass +class NavContext: + """Context for table navigation operations.""" + + parsed: list[tuple[str, int | None]] + create: bool + + +# ============================================================================= +# Path Parsing Functions +# ============================================================================= + + +def parse_table_path(table_path: str | None) -> list[tuple[str, int | None]]: + """Parse a table path with optional array indices.""" if not table_path: return [] @@ -353,7 +375,172 @@ def _format_path(parsed: list[tuple[str, int | None]], up_to: int | None = None) return ".".join(name + (f"[{idx}]" if idx is not None else "") for name, idx in parsed[:up_to]) +# ============================================================================= +# Value Conversion Functions (dispatch pattern for O(1) lookup) +# ============================================================================= + + +def _convert_auto(value): + """Auto-detect and convert value type.""" + if isinstance(value, (bool, int, float)): + return value + if isinstance(value, str): + return _try_parse_datetime_string(value) + if isinstance(value, list): + return _convert_list_to_array(value) + if isinstance(value, dict): + return _convert_dict_to_inline_table(value) + return str(value) + + +def _try_parse_datetime_string(value): + """Try parsing string as datetime, return as-is if not.""" + if "T" in value or " " in value: + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except (ValueError, AttributeError): + pass + return value + + +def _convert_list_to_array(value): + """Convert Python list to TOML array.""" + arr = tomlkit.array() + for item in value: + arr.append(convert_value(item, "auto")) + return arr + + +def _convert_dict_to_inline_table(value): + """Convert Python dict to TOML inline table.""" + tbl = tomlkit.inline_table() + for k, v in value.items(): + tbl[k] = convert_value(v, "auto") + return tbl + + +def _convert_string(value): + return str(value) + + +def _convert_literal_string(value): + return tomlkit.string(str(value), literal=True) + + +def _convert_multiline_string(value): + return tomlkit.string(str(value), multiline=True) + + +def _convert_multiline_literal_string(value): + return tomlkit.string(str(value), literal=True, multiline=True) + + +def _convert_integer(value): + return int(value) + + +def _convert_hex_integer(value): + int_val = int(value) if isinstance(value, int) else int(str(value), 16) + return Integer(int_val, Trivia(), hex(int_val)) + + +def _convert_octal_integer(value): + int_val = int(value) if isinstance(value, int) else int(str(value), 8) + return Integer(int_val, Trivia(), oct(int_val)) + + +def _convert_binary_integer(value): + int_val = int(value) if isinstance(value, int) else int(str(value), 2) + return Integer(int_val, Trivia(), bin(int_val)) + + +def _convert_float(value): + if isinstance(value, str): + return float(value.lower()) + return float(value) + + +def _convert_boolean(value): + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() in ("true", "yes", "1", "on") + return bool(value) + + +def _convert_datetime(value): + if isinstance(value, datetime): + return value + return datetime.fromisoformat(str(value).replace("Z", "+00:00")) + + +def _convert_date(value): + from datetime import date as date_type + + if isinstance(value, date_type) and not isinstance(value, datetime): + return tomlkit.date(value.isoformat()) + return tomlkit.date(str(value)) + + +def _convert_time(value): + from datetime import time as time_type + + if isinstance(value, time_type): + return tomlkit.time(value.isoformat()) + return tomlkit.time(str(value)) + + +def _convert_array(value): + if isinstance(value, list): + return _convert_list_to_array(value) + arr = tomlkit.array() + arr.append(convert_value(value, "auto")) + return arr + + +def _convert_inline_table(value): + if not isinstance(value, dict): + raise ValueError(f"Cannot convert {type(value).__name__} to inline_table") + return _convert_dict_to_inline_table(value) + + +# Dispatch dict for O(1) type lookup +_CONVERTERS = { + "string": _convert_string, + "literal_string": _convert_literal_string, + "multiline_string": _convert_multiline_string, + "multiline_literal_string": _convert_multiline_literal_string, + "integer": _convert_integer, + "hex_integer": _convert_hex_integer, + "octal_integer": _convert_octal_integer, + "binary_integer": _convert_binary_integer, + "float": _convert_float, + "boolean": _convert_boolean, + "datetime": _convert_datetime, + "date": _convert_date, + "time": _convert_time, + "array": _convert_array, + "inline_table": _convert_inline_table, +} + + +def convert_value(value, value_type="auto"): + """Convert a value to the appropriate TOML type.""" + if value_type == "auto": + return _convert_auto(value) + converter = _CONVERTERS.get(value_type) + if converter: + return converter(value) + return value + + +# ============================================================================= +# Table Navigation Functions (extracted for reduced nesting) +# ============================================================================= + + def load_toml_document(path, create=True): + """Load a TOML document from file.""" if not os.path.exists(path): if not create: raise TomlFileError(f"Destination {path} does not exist!") @@ -366,160 +553,104 @@ def load_toml_document(path, create=True): raise TomlFileError(f"Failed to parse TOML file: {e}") from e -def navigate_to_table(doc, table_path, create=False): - """Navigate to a table in the document. +def _create_new_aot_entry(current, name): + """Create a new array of tables with first entry.""" + new_table = tomlkit.table() + aot = tomlkit.aot() + aot.append(new_table) + current[name] = aot + return new_table - The table_path can include array indices using bracket notation: - - "products[0]" - first entry in products array of tables - - "products[-1]" - last entry (or append new if create=True) - - "servers[1].databases[0]" - nested arrays - """ + +def _create_regular_table(current, name): + """Create a regular table.""" + new_table = tomlkit.table() + current[name] = new_table + return new_table + + +def _create_table_segment(current, name, index, ctx, i): + """Create a new table segment when name not in current.""" + if not ctx.create: + raise TomlFileError(f"Table '{_format_path(ctx.parsed, i + 1)}' does not exist") + if index == -1: + return _create_new_aot_entry(current, name) + if index is not None: + raise TomlFileError(f"Cannot create table at index {index} - array of tables '{name}' does not exist") + return _create_regular_table(current, name) + + +def _get_aot_default(aot, ctx, i): + """Get first entry from AoT when no index specified.""" + if len(aot) == 0: + raise TomlFileError(f"Array of tables '{_format_path(ctx.parsed, i + 1)}' is empty") + return aot[0] + + +def _get_aot_at_index(aot, index, ctx, i): + """Get AoT entry at specific index.""" + if 0 <= index < len(aot): + return aot[index] + raise TomlFileError(f"Array of tables index {index} is out of range for '{_format_path(ctx.parsed, i + 1)}'") + + +def _handle_aot_append(aot, is_last, ctx, i): + """Handle AoT[-1] - append new or get last entry.""" + if is_last and ctx.create: + new_table = tomlkit.table() + new_table.trivia.indent = '\n' + aot.append(new_table) + return new_table + if len(aot) == 0: + raise TomlFileError(f"Array of tables '{_format_path(ctx.parsed, i + 1)}' is empty") + return aot[-1] + + +def _navigate_aot(aot, index, is_last, ctx, i): + """Navigate within an array of tables.""" + if index is None: + return _get_aot_default(aot, ctx, i) + if index == -1: + return _handle_aot_append(aot, is_last, ctx, i) + return _get_aot_at_index(aot, index, ctx, i) + + +def _navigate_existing_segment(current, name, index, ctx, i): + """Navigate into an existing table segment.""" + item = current[name] + is_last = i == len(ctx.parsed) - 1 + if isinstance(item, AoT): + return _navigate_aot(item, index, is_last, ctx, i) + if isinstance(item, (Table, dict)): + if index is not None: + raise TomlFileError(f"'{_format_path(ctx.parsed, i + 1)}' is not an array of tables, cannot use index") + return item + raise TomlFileError(f"'{_format_path(ctx.parsed, i + 1)}' is not a table") + + +def navigate_to_table(doc, table_path, create=False): + """Navigate to a table in the document.""" parsed = parse_table_path(table_path) if not parsed: return doc + ctx = NavContext(parsed=parsed, create=create) current = doc - for i, (name, index) in enumerate(parsed): - is_last = i == len(parsed) - 1 - if name not in current: - if not create: - raise TomlFileError(f"Table '{_format_path(parsed, i + 1)}' does not exist") - - if index == -1: - new_table = tomlkit.table() - aot = tomlkit.aot() - aot.append(new_table) - current[name] = aot - current = new_table - elif index is not None: - raise TomlFileError(f"Cannot create table at index {index} - array of tables '{name}' does not exist") - else: - new_table = tomlkit.table() - current[name] = new_table - current = new_table + current = _create_table_segment(current, name, index, ctx, i) else: - item = current[name] - if isinstance(item, AoT): - if index is not None: - if index == -1: - if is_last and create: - new_table = tomlkit.table() - new_table.trivia.indent = '\n' - item.append(new_table) - current = new_table - else: - if len(item) == 0: - raise TomlFileError(f"Array of tables '{_format_path(parsed, i + 1)}' is empty") - current = item[-1] - elif 0 <= index < len(item): - current = item[index] - else: - raise TomlFileError( - f"Array of tables index {index} is out of range for '{_format_path(parsed, i + 1)}'" - ) - else: - if len(item) == 0: - raise TomlFileError(f"Array of tables '{_format_path(parsed, i + 1)}' is empty") - current = item[0] - elif isinstance(item, (Table, dict)): - if index is not None: - raise TomlFileError(f"'{_format_path(parsed, i + 1)}' is not an array of tables, cannot use index") - current = item - else: - raise TomlFileError(f"'{_format_path(parsed, i + 1)}' is not a table") - + current = _navigate_existing_segment(current, name, index, ctx, i) return current -def convert_value(value, value_type="auto"): - if value_type == "auto": - if isinstance(value, (bool, int, float)): - return value - if isinstance(value, str): - if "T" in value or " " in value: - try: - return datetime.fromisoformat(value.replace("Z", "+00:00")) - except (ValueError, AttributeError): - pass - return value - if isinstance(value, list): - arr = tomlkit.array() - for item in value: - arr.append(convert_value(item, "auto")) - return arr - if isinstance(value, dict): - tbl = tomlkit.inline_table() - for k, v in value.items(): - tbl[k] = convert_value(v, "auto") - return tbl - return str(value) - if value_type == "string": - return str(value) - if value_type == "literal_string": - return tomlkit.string(str(value), literal=True) - if value_type == "multiline_string": - return tomlkit.string(str(value), multiline=True) - if value_type == "multiline_literal_string": - return tomlkit.string(str(value), literal=True, multiline=True) - if value_type == "integer": - return int(value) - if value_type == "hex_integer": - int_val = int(value) if isinstance(value, int) else int(str(value), 16) - return Integer(int_val, Trivia(), hex(int_val)) - if value_type == "octal_integer": - int_val = int(value) if isinstance(value, int) else int(str(value), 8) - return Integer(int_val, Trivia(), oct(int_val)) - if value_type == "binary_integer": - int_val = int(value) if isinstance(value, int) else int(str(value), 2) - return Integer(int_val, Trivia(), bin(int_val)) - if value_type == "float": - if isinstance(value, str): - return float(value.lower()) - return float(value) - if value_type == "boolean": - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.lower() in ("true", "yes", "1", "on") - return bool(value) - if value_type == "datetime": - if isinstance(value, datetime): - return value - return datetime.fromisoformat(str(value).replace("Z", "+00:00")) - if value_type == "date": - from datetime import date as date_type - - if isinstance(value, date_type) and not isinstance(value, datetime): - return tomlkit.date(value.isoformat()) - return tomlkit.date(str(value)) - if value_type == "time": - from datetime import time as time_type - - if isinstance(value, time_type): - return tomlkit.time(value.isoformat()) - return tomlkit.time(str(value)) - if value_type == "array": - if isinstance(value, list): - arr = tomlkit.array() - for item in value: - arr.append(convert_value(item, "auto")) - return arr - arr = tomlkit.array() - arr.append(convert_value(value, "auto")) - return arr - if value_type == "inline_table": - if not isinstance(value, dict): - raise ValueError(f"Cannot convert {type(value).__name__} to inline_table") - tbl = tomlkit.inline_table() - for k, v in value.items(): - tbl[k] = convert_value(v, "auto") - return tbl - return value +# ============================================================================= +# Key Operations +# ============================================================================= def navigate_to_key_parent(target, key): + """Navigate to the parent container of a key.""" parts = key.split(".") if len(parts) == 1: return target, key @@ -540,7 +671,24 @@ def navigate_to_key_parent(target, key): return parent, parts[-1] +def _values_equal(a, b): + """Check if two values are equal.""" + if isinstance(a, Array) and isinstance(b, (Array, list)): + if len(a) != len(b): + return False + return all(_values_equal(x, y) for x, y in zip(a, b)) + if isinstance(a, (InlineTable, Table)) and isinstance(b, (InlineTable, Table, dict)): + if len(a) != len(b): + return False + for k in a: + if k not in b or not _values_equal(a[k], b[k]): + return False + return True + return a == b + + def set_key_value(target, key, value, value_type="auto"): + """Set a key to a value in the target table.""" parent, final_key = navigate_to_key_parent(target, key) converted_value = convert_value(value, value_type) @@ -553,19 +701,8 @@ def set_key_value(target, key, value, value_type="auto"): return True -def _values_equal(a, b): - if isinstance(a, Array) and isinstance(b, (Array, list)): - if len(a) != len(b): - return False - return all(_values_equal(x, y) for x, y in zip(a, b)) - if isinstance(a, (InlineTable, Table)) and isinstance(b, (InlineTable, Table, dict)): - if a.keys() != b.keys(): - return False - return all(_values_equal(a[k], b[k]) for k in a) - return a == b - - def remove_key(target, key): + """Remove a key from the target table.""" parts = key.split(".") parent = target for part in parts[:-1]: @@ -584,150 +721,159 @@ def remove_key(target, key): return False -def remove_table(doc, table_path): - """Remove a table from the document. +# ============================================================================= +# Table Removal +# ============================================================================= - The table_path can include array indices using bracket notation: - - "products[0]" - remove first entry from products array of tables - - "products[-1]" - remove last entry - - "products" - remove entire array of tables - """ + +def remove_table(doc, table_path): + """Remove a table from the document.""" parsed = parse_table_path(table_path) if not parsed: raise TomlFileError("Cannot remove document root") - # Navigate to parent of the target if len(parsed) == 1: parent = doc else: try: - parent_path = _format_path(parsed[:-1]) + parent_path = _format_path(parsed, len(parsed) - 1) parent = navigate_to_table(doc, parent_path, create=False) except TomlFileError: return False name, index = parsed[-1] - if name not in parent: return False item = parent[name] if isinstance(item, AoT) and index is not None: - if index == -1: - if len(item) > 0: - del item[-1] - return True - return False - elif 0 <= index < len(item): - del item[index] - return True - raise TomlFileError(f"Array of tables index {index} out of range") + return _remove_aot_entry(item, index) - # If it's an AoT but no index specified, or a regular table, remove entirely del parent[name] return True -def do_toml( - module, - path, - table, - key, - value, - value_type, - state, - backup, - create, - follow, -): +def _remove_aot_entry(aot, index): + """Remove an entry from an array of tables.""" + if index == -1: + if len(aot) > 0: + del aot[-1] + return True + return False + if 0 <= index < len(aot): + del aot[index] + return True + raise TomlFileError(f"Array of tables index {index} out of range") + + +# ============================================================================= +# Main TOML Operations (extracted for reduced nesting and statements) +# ============================================================================= + + +def _init_diff(path, diff_enabled, doc): + """Initialize the diff dictionary.""" diff = { "before": "", "after": "", "before_header": f"{path} (content)", "after_header": f"{path} (content)", } - - if follow and os.path.islink(path): - target_path = os.path.realpath(path) - else: - target_path = path - - doc = load_toml_document(target_path, create) - - if module._diff: + if diff_enabled: diff["before"] = tomlkit.dumps(doc) + return diff + +def _handle_present(doc, params, original_content): + """Handle state=present operations.""" + if params.key: + if params.value is None: + raise TomlFileError("Parameter 'value' is required when state=present and key is specified") + target = navigate_to_table(doc, params.table, create=True) + changed = set_key_value(target, params.key, params.value, params.value_type) + msg = "key added" if changed else "OK" + return changed, msg + + if params.table: + navigate_to_table(doc, params.table, create=True) + new_content = tomlkit.dumps(doc) + if new_content != original_content: + return True, "table added" + return False, "nothing to do" + + +def _handle_absent(doc, params): + """Handle state=absent operations.""" + if params.key: + try: + target = navigate_to_table(doc, params.table, create=False) + changed = remove_key(target, params.key) + return changed, "key removed" if changed else "key not found" + except TomlFileError: + return False, "key not found" + + if params.table: + changed = remove_table(doc, params.table) + return changed, "table removed" if changed else "table not found" + return False, "nothing to do" + + +def _write_if_changed(module, target_path, doc, changed, backup): + """Write the document to file if changed.""" + if not changed or module.check_mode: + return None + + backup_file = None + if backup and os.path.exists(target_path): + backup_file = module.backup_local(target_path) + + destpath = os.path.dirname(target_path) + if destpath and not os.path.exists(destpath): + os.makedirs(destpath) + + content = tomlkit.dumps(doc) + encoded_content = to_bytes(content) + + try: + tmpfd, tmpfile = tempfile.mkstemp(dir=module.tmpdir) + with os.fdopen(tmpfd, "wb") as f: + f.write(encoded_content) + except OSError: + module.fail_json(msg="Unable to create temporary file", traceback=traceback.format_exc()) + + try: + module.atomic_move(tmpfile, os.path.abspath(target_path)) + except OSError: + module.fail_json(msg=f"Unable to move temporary file {tmpfile} to {target_path}", traceback=traceback.format_exc()) + + return backup_file + + +def do_toml(module, params: TomlParams): + """Execute the main TOML file operation.""" + target_path = os.path.realpath(params.path) if params.follow and os.path.islink(params.path) else params.path + doc = load_toml_document(target_path, params.create) + + diff = _init_diff(params.path, module._diff, doc) original_content = tomlkit.dumps(doc) - changed = False - msg = "OK" - if state == "present": - if key: - if value is None: - raise TomlFileError("Parameter 'value' is required when state=present and key is specified") - - target = navigate_to_table(doc, table, create=True) - changed = set_key_value(target, key, value, value_type) - - if changed: - msg = "key added" if key not in target or changed else "key changed" - else: - if table: - navigate_to_table(doc, table, create=True) - new_content = tomlkit.dumps(doc) - if new_content != original_content: - changed = True - msg = "table added" - else: - msg = "nothing to do" - elif state == "absent": - if key: - try: - target = navigate_to_table(doc, table, create=False) - changed = remove_key(target, key) - msg = "key removed" if changed else "key not found" - except TomlFileError: - msg = "key not found" - else: - if table: - changed = remove_table(doc, table) - msg = "table removed" if changed else "table not found" - else: - msg = "nothing to do" + if params.state == "present": + changed, msg = _handle_present(doc, params, original_content) + else: + changed, msg = _handle_absent(doc, params) if module._diff: diff["after"] = tomlkit.dumps(doc) - backup_file = None - if changed and not module.check_mode: - if backup and os.path.exists(target_path): - backup_file = module.backup_local(target_path) - - destpath = os.path.dirname(target_path) - if destpath and not os.path.exists(destpath): - os.makedirs(destpath) - - content = tomlkit.dumps(doc) - encoded_content = to_bytes(content) - - try: - tmpfd, tmpfile = tempfile.mkstemp(dir=module.tmpdir) - with os.fdopen(tmpfd, "wb") as f: - f.write(encoded_content) - except OSError: - module.fail_json(msg="Unable to create temporary file", traceback=traceback.format_exc()) - - try: - module.atomic_move(tmpfile, os.path.abspath(target_path)) - except OSError: - module.fail_json( - msg=f"Unable to move temporary file {tmpfile} to {target_path}", - traceback=traceback.format_exc(), - ) - + backup_file = _write_if_changed(module, target_path, doc, changed, params.backup) return changed, backup_file, diff, msg +# ============================================================================= +# Module Entry Point +# ============================================================================= + + def main() -> None: module = AnsibleModule( argument_spec=dict( @@ -768,24 +914,24 @@ def main() -> None: deps.validate(module) - path = module.params["path"] - table = module.params["table"] - key = module.params["key"] - value = module.params["value"] - value_type = module.params["value_type"] - state = module.params["state"] - backup = module.params["backup"] - create = module.params["create"] - follow = module.params["follow"] + params = TomlParams( + path=module.params["path"], + table=module.params["table"], + key=module.params["key"], + value=module.params["value"], + value_type=module.params["value_type"], + state=module.params["state"], + backup=module.params["backup"], + create=module.params["create"], + follow=module.params["follow"], + ) try: - changed, backup_file, diff, msg = do_toml( - module, path, table, key, value, value_type, state, backup, create, follow - ) + changed, backup_file, diff, msg = do_toml(module, params) except TomlFileError as e: module.fail_json(msg=str(e)) - if not module.check_mode and os.path.exists(path): + if not module.check_mode and os.path.exists(params.path): file_args = module.load_file_common_arguments(module.params) changed = module.set_fs_attributes_if_different(file_args, changed) @@ -793,7 +939,7 @@ def main() -> None: changed=changed, diff=diff, msg=msg, - path=path, + path=params.path, ) if backup_file is not None: results["backup_file"] = backup_file From 39faeea6083400309720e1ac568c17faf0228b5b Mon Sep 17 00:00:00 2001 From: Jose Drowne Date: Fri, 23 Jan 2026 17:04:08 -0500 Subject: [PATCH 06/17] Fix ruff format and SIM110 lint error - Run ruff format to fix formatting issues - Replace for loop with all() in _values_equal() to fix SIM110 Co-Authored-By: Claude Opus 4.5 --- plugins/modules/toml_file.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/plugins/modules/toml_file.py b/plugins/modules/toml_file.py index a9f7956f85..79011e369b 100644 --- a/plugins/modules/toml_file.py +++ b/plugins/modules/toml_file.py @@ -598,7 +598,7 @@ def _handle_aot_append(aot, is_last, ctx, i): """Handle AoT[-1] - append new or get last entry.""" if is_last and ctx.create: new_table = tomlkit.table() - new_table.trivia.indent = '\n' + new_table.trivia.indent = "\n" aot.append(new_table) return new_table if len(aot) == 0: @@ -680,10 +680,7 @@ def _values_equal(a, b): if isinstance(a, (InlineTable, Table)) and isinstance(b, (InlineTable, Table, dict)): if len(a) != len(b): return False - for k in a: - if k not in b or not _values_equal(a[k], b[k]): - return False - return True + return all(k in b and _values_equal(a[k], b[k]) for k in a) return a == b @@ -844,7 +841,9 @@ def _write_if_changed(module, target_path, doc, changed, backup): try: module.atomic_move(tmpfile, os.path.abspath(target_path)) except OSError: - module.fail_json(msg=f"Unable to move temporary file {tmpfile} to {target_path}", traceback=traceback.format_exc()) + module.fail_json( + msg=f"Unable to move temporary file {tmpfile} to {target_path}", traceback=traceback.format_exc() + ) return backup_file From 1a6876ed41086e04c04e80a458647beb118e0027 Mon Sep 17 00:00:00 2001 From: Jose Drowne Date: Fri, 23 Jan 2026 19:04:45 -0500 Subject: [PATCH 07/17] Fix test-table 4 expected value to include trailing blank line Use |+ YAML block scalar indicator to preserve trailing blank lines. tomlkit preserves the blank line that preceded the removed table, so the expected output must include it. Co-Authored-By: Claude Opus 4.5 --- tests/integration/targets/toml_file/tasks/01-tables.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/targets/toml_file/tasks/01-tables.yml b/tests/integration/targets/toml_file/tasks/01-tables.yml index 9a82d75b6d..fda2e26aae 100644 --- a/tests/integration/targets/toml_file/tasks/01-tables.yml +++ b/tests/integration/targets/toml_file/tasks/01-tables.yml @@ -112,7 +112,7 @@ - name: test-table 4 - verify table removed vars: # Note: tomlkit preserves the blank line that preceded the removed table - expected: | + expected: |+ [database] host = "localhost" port = 5432 From 1316da4e732e7893b95e30f77d20bc17bc403014 Mon Sep 17 00:00:00 2001 From: Jose Drowne Date: Fri, 23 Jan 2026 19:38:37 -0500 Subject: [PATCH 08/17] Fix expected values for trailing blank line after AoT entry removal tomlkit preserves blank lines when removing array of tables entries, so the expected value needs |+ to keep the trailing blank line. Co-Authored-By: Claude Opus 4.5 --- tests/integration/targets/toml_file/tasks/04-array-tables.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/targets/toml_file/tasks/04-array-tables.yml b/tests/integration/targets/toml_file/tasks/04-array-tables.yml index a787ff378a..e6c8eb6b3a 100644 --- a/tests/integration/targets/toml_file/tasks/04-array-tables.yml +++ b/tests/integration/targets/toml_file/tasks/04-array-tables.yml @@ -174,7 +174,7 @@ - name: test-aot 6 - verify last entry removed vars: # Note: tomlkit preserves the blank line that preceded the removed entry - expected: | + expected: |+ [[products]] name = "Hammer" From 1c2b36144d8db94bdff9824b30db3179c90358ad Mon Sep 17 00:00:00 2001 From: Jose Drowne Date: Sun, 25 Jan 2026 09:46:38 -0500 Subject: [PATCH 09/17] Use typing module alias instead of direct Any import Change from `from typing import Any` to `import typing as t` and use `t.Any` for type annotations, following project import conventions. Co-Authored-By: Claude Opus 4.5 --- plugins/modules/toml_file.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/modules/toml_file.py b/plugins/modules/toml_file.py index 79011e369b..872894e4c4 100644 --- a/plugins/modules/toml_file.py +++ b/plugins/modules/toml_file.py @@ -302,9 +302,9 @@ import os import re import tempfile import traceback +import typing as t from dataclasses import dataclass from datetime import datetime -from typing import Any from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.common.text.converters import to_bytes @@ -327,7 +327,7 @@ class TomlParams: path: str table: str | None key: str | None - value: Any + value: t.Any value_type: str state: str backup: bool From ba47a0f22e54766d43c01cc6a89045d0c76c0266 Mon Sep 17 00:00:00 2001 From: Jose Drowne Date: Sun, 25 Jan 2026 11:04:35 -0500 Subject: [PATCH 10/17] Improve toml_file module with [append] syntax and type hints - Replace [-1] with [append] syntax for appending new AoT entries - [-1] now uses standard Python semantics (last existing entry) - [append] explicitly creates a new entry - Errors when [append] used with state=absent or mid-path - Add type hints to all functions - Document empty string as equivalent to omitted table parameter - Remove common return values (msg, diff) from RETURN docs - Add 'auto' to _CONVERTERS dispatch dict for consistency - Make parse_table_path explicit about None/empty string handling Co-Authored-By: Claude Opus 4.5 --- plugins/modules/toml_file.py | 168 +++++++++--------- .../toml_file/tasks/04-array-tables.yml | 4 +- 2 files changed, 86 insertions(+), 86 deletions(-) diff --git a/plugins/modules/toml_file.py b/plugins/modules/toml_file.py index 872894e4c4..8aa1f8809f 100644 --- a/plugins/modules/toml_file.py +++ b/plugins/modules/toml_file.py @@ -35,9 +35,9 @@ options: - Table name in the TOML file. - Use dotted notation for nested tables (for example, V(server.database)). - For array of tables (C([[table]])), use bracket notation to specify the index - (for example, V(products[0]) for the first entry, V(products[-1]) to append a new entry). + (for example, V(products[0]) for the first entry, V(products[append]) to append a new entry). - Nested arrays of tables are supported (for example, V(servers[1].databases[0])). - - If omitted, the key is set at the document root level. + - If omitted or empty string, the key is set at the document root level. type: str key: description: @@ -166,7 +166,7 @@ EXAMPLES = r""" - name: Add entry to array of tables community.general.toml_file: path: /etc/myapp/config.toml - table: products[-1] + table: products[append] key: name value: Hammer @@ -275,27 +275,11 @@ path: returned: success type: str sample: /etc/myapp/config.toml -msg: - description: A message describing what was done. - returned: always - type: str - sample: key added backup_file: description: The name of the backup file that was created. returned: when O(backup=true) and file was modified type: str sample: /etc/myapp/config.toml.2026-01-17@12:34:56~ -diff: - description: The differences between the old and new file. - returned: success and diff mode is enabled - type: dict - contains: - before: - description: The content of the file before modification. - type: str - after: - description: The content of the file after modification. - type: str """ import os @@ -339,7 +323,7 @@ class TomlParams: class NavContext: """Context for table navigation operations.""" - parsed: list[tuple[str, int | None]] + parsed: list[tuple[str, int | str | None]] create: bool @@ -348,13 +332,13 @@ class NavContext: # ============================================================================= -def parse_table_path(table_path: str | None) -> list[tuple[str, int | None]]: +def parse_table_path(table_path: str | None) -> list[tuple[str, int | str | None]]: """Parse a table path with optional array indices.""" - if not table_path: - return [] + if table_path is None or table_path == "": + return [] # Empty list means document root result = [] - segment_pattern = re.compile(r"^([^\[\]]+)(?:\[(-?\d+)\])?$") + segment_pattern = re.compile(r"^([^\[\]]+)(?:\[(-?\d+|append)\])?$") for segment in table_path.split("."): match = segment_pattern.match(segment) @@ -362,13 +346,18 @@ def parse_table_path(table_path: str | None) -> list[tuple[str, int | None]]: raise TomlFileError(f"Invalid table path segment: '{segment}'") name = match.group(1) index_str = match.group(2) - index = int(index_str) if index_str is not None else None + if index_str is None: + index = None + elif index_str == "append": + index = "append" + else: + index = int(index_str) result.append((name, index)) return result -def _format_path(parsed: list[tuple[str, int | None]], up_to: int | None = None) -> str: +def _format_path(parsed: list[tuple[str, int | str | None]], up_to: int | None = None) -> str: """Format a parsed path back to string for error messages.""" if up_to is None: up_to = len(parsed) @@ -380,7 +369,7 @@ def _format_path(parsed: list[tuple[str, int | None]], up_to: int | None = None) # ============================================================================= -def _convert_auto(value): +def _convert_auto(value: t.Any) -> t.Any: """Auto-detect and convert value type.""" if isinstance(value, (bool, int, float)): return value @@ -393,7 +382,7 @@ def _convert_auto(value): return str(value) -def _try_parse_datetime_string(value): +def _try_parse_datetime_string(value: str) -> str | datetime: """Try parsing string as datetime, return as-is if not.""" if "T" in value or " " in value: try: @@ -403,7 +392,7 @@ def _try_parse_datetime_string(value): return value -def _convert_list_to_array(value): +def _convert_list_to_array(value: list[t.Any]) -> Array: """Convert Python list to TOML array.""" arr = tomlkit.array() for item in value: @@ -411,7 +400,7 @@ def _convert_list_to_array(value): return arr -def _convert_dict_to_inline_table(value): +def _convert_dict_to_inline_table(value: dict[str, t.Any]) -> InlineTable: """Convert Python dict to TOML inline table.""" tbl = tomlkit.inline_table() for k, v in value.items(): @@ -419,48 +408,48 @@ def _convert_dict_to_inline_table(value): return tbl -def _convert_string(value): +def _convert_string(value: t.Any) -> str: return str(value) -def _convert_literal_string(value): +def _convert_literal_string(value: t.Any) -> str: return tomlkit.string(str(value), literal=True) -def _convert_multiline_string(value): +def _convert_multiline_string(value: t.Any) -> str: return tomlkit.string(str(value), multiline=True) -def _convert_multiline_literal_string(value): +def _convert_multiline_literal_string(value: t.Any) -> str: return tomlkit.string(str(value), literal=True, multiline=True) -def _convert_integer(value): +def _convert_integer(value: t.Any) -> int: return int(value) -def _convert_hex_integer(value): +def _convert_hex_integer(value: t.Any) -> Integer: int_val = int(value) if isinstance(value, int) else int(str(value), 16) return Integer(int_val, Trivia(), hex(int_val)) -def _convert_octal_integer(value): +def _convert_octal_integer(value: t.Any) -> Integer: int_val = int(value) if isinstance(value, int) else int(str(value), 8) return Integer(int_val, Trivia(), oct(int_val)) -def _convert_binary_integer(value): +def _convert_binary_integer(value: t.Any) -> Integer: int_val = int(value) if isinstance(value, int) else int(str(value), 2) return Integer(int_val, Trivia(), bin(int_val)) -def _convert_float(value): +def _convert_float(value: t.Any) -> float: if isinstance(value, str): return float(value.lower()) return float(value) -def _convert_boolean(value): +def _convert_boolean(value: t.Any) -> bool: if isinstance(value, bool): return value if isinstance(value, str): @@ -468,13 +457,13 @@ def _convert_boolean(value): return bool(value) -def _convert_datetime(value): +def _convert_datetime(value: t.Any) -> datetime: if isinstance(value, datetime): return value return datetime.fromisoformat(str(value).replace("Z", "+00:00")) -def _convert_date(value): +def _convert_date(value: t.Any) -> t.Any: from datetime import date as date_type if isinstance(value, date_type) and not isinstance(value, datetime): @@ -482,7 +471,7 @@ def _convert_date(value): return tomlkit.date(str(value)) -def _convert_time(value): +def _convert_time(value: t.Any) -> t.Any: from datetime import time as time_type if isinstance(value, time_type): @@ -490,7 +479,7 @@ def _convert_time(value): return tomlkit.time(str(value)) -def _convert_array(value): +def _convert_array(value: t.Any) -> Array: if isinstance(value, list): return _convert_list_to_array(value) arr = tomlkit.array() @@ -498,7 +487,7 @@ def _convert_array(value): return arr -def _convert_inline_table(value): +def _convert_inline_table(value: t.Any) -> InlineTable: if not isinstance(value, dict): raise ValueError(f"Cannot convert {type(value).__name__} to inline_table") return _convert_dict_to_inline_table(value) @@ -506,6 +495,7 @@ def _convert_inline_table(value): # Dispatch dict for O(1) type lookup _CONVERTERS = { + "auto": _convert_auto, "string": _convert_string, "literal_string": _convert_literal_string, "multiline_string": _convert_multiline_string, @@ -524,10 +514,8 @@ _CONVERTERS = { } -def convert_value(value, value_type="auto"): +def convert_value(value: t.Any, value_type: str = "auto") -> t.Any: """Convert a value to the appropriate TOML type.""" - if value_type == "auto": - return _convert_auto(value) converter = _CONVERTERS.get(value_type) if converter: return converter(value) @@ -539,7 +527,7 @@ def convert_value(value, value_type="auto"): # ============================================================================= -def load_toml_document(path, create=True): +def load_toml_document(path: str, create: bool = True) -> tomlkit.TOMLDocument: """Load a TOML document from file.""" if not os.path.exists(path): if not create: @@ -553,7 +541,7 @@ def load_toml_document(path, create=True): raise TomlFileError(f"Failed to parse TOML file: {e}") from e -def _create_new_aot_entry(current, name): +def _create_new_aot_entry(current: Table | tomlkit.TOMLDocument, name: str) -> Table: """Create a new array of tables with first entry.""" new_table = tomlkit.table() aot = tomlkit.aot() @@ -562,60 +550,64 @@ def _create_new_aot_entry(current, name): return new_table -def _create_regular_table(current, name): +def _create_regular_table(current: Table | tomlkit.TOMLDocument, name: str) -> Table: """Create a regular table.""" new_table = tomlkit.table() current[name] = new_table return new_table -def _create_table_segment(current, name, index, ctx, i): +def _create_table_segment( + current: Table | tomlkit.TOMLDocument, name: str, index: int | str | None, ctx: NavContext, i: int +) -> Table: """Create a new table segment when name not in current.""" if not ctx.create: raise TomlFileError(f"Table '{_format_path(ctx.parsed, i + 1)}' does not exist") - if index == -1: + if index == "append": return _create_new_aot_entry(current, name) if index is not None: raise TomlFileError(f"Cannot create table at index {index} - array of tables '{name}' does not exist") return _create_regular_table(current, name) -def _get_aot_default(aot, ctx, i): +def _get_aot_default(aot: AoT, ctx: NavContext, i: int) -> Table: """Get first entry from AoT when no index specified.""" if len(aot) == 0: raise TomlFileError(f"Array of tables '{_format_path(ctx.parsed, i + 1)}' is empty") return aot[0] -def _get_aot_at_index(aot, index, ctx, i): +def _get_aot_at_index(aot: AoT, index: int, ctx: NavContext, i: int) -> Table: """Get AoT entry at specific index.""" if 0 <= index < len(aot): return aot[index] raise TomlFileError(f"Array of tables index {index} is out of range for '{_format_path(ctx.parsed, i + 1)}'") -def _handle_aot_append(aot, is_last, ctx, i): - """Handle AoT[-1] - append new or get last entry.""" - if is_last and ctx.create: - new_table = tomlkit.table() - new_table.trivia.indent = "\n" - aot.append(new_table) - return new_table - if len(aot) == 0: - raise TomlFileError(f"Array of tables '{_format_path(ctx.parsed, i + 1)}' is empty") - return aot[-1] +def _handle_aot_append(aot: AoT, is_last: bool, ctx: NavContext, i: int) -> Table: + """Handle AoT[append] - append a new entry to the array of tables.""" + if not is_last: + raise TomlFileError(f"Cannot use [append] in middle of path '{_format_path(ctx.parsed)}'") + if not ctx.create: + raise TomlFileError("Cannot use [append] with state=absent; use [-1] to access the last entry") + new_table = tomlkit.table() + new_table.trivia.indent = "\n" + aot.append(new_table) + return new_table -def _navigate_aot(aot, index, is_last, ctx, i): +def _navigate_aot(aot: AoT, index: int | str | None, is_last: bool, ctx: NavContext, i: int) -> Table: """Navigate within an array of tables.""" if index is None: return _get_aot_default(aot, ctx, i) - if index == -1: + if index == "append": return _handle_aot_append(aot, is_last, ctx, i) return _get_aot_at_index(aot, index, ctx, i) -def _navigate_existing_segment(current, name, index, ctx, i): +def _navigate_existing_segment( + current: Table | tomlkit.TOMLDocument, name: str, index: int | str | None, ctx: NavContext, i: int +) -> Table | tomlkit.TOMLDocument: """Navigate into an existing table segment.""" item = current[name] is_last = i == len(ctx.parsed) - 1 @@ -628,14 +620,16 @@ def _navigate_existing_segment(current, name, index, ctx, i): raise TomlFileError(f"'{_format_path(ctx.parsed, i + 1)}' is not a table") -def navigate_to_table(doc, table_path, create=False): +def navigate_to_table( + doc: tomlkit.TOMLDocument, table_path: str | None, create: bool = False +) -> Table | tomlkit.TOMLDocument: """Navigate to a table in the document.""" parsed = parse_table_path(table_path) if not parsed: return doc ctx = NavContext(parsed=parsed, create=create) - current = doc + current: Table | tomlkit.TOMLDocument = doc for i, (name, index) in enumerate(parsed): if name not in current: current = _create_table_segment(current, name, index, ctx, i) @@ -649,13 +643,15 @@ def navigate_to_table(doc, table_path, create=False): # ============================================================================= -def navigate_to_key_parent(target, key): +def navigate_to_key_parent( + target: Table | tomlkit.TOMLDocument, key: str +) -> tuple[Table | InlineTable | tomlkit.TOMLDocument, str]: """Navigate to the parent container of a key.""" parts = key.split(".") if len(parts) == 1: return target, key - parent = target + parent: Table | InlineTable | tomlkit.TOMLDocument = target for i, part in enumerate(parts[:-1]): if part not in parent: new_table = tomlkit.inline_table() @@ -671,7 +667,7 @@ def navigate_to_key_parent(target, key): return parent, parts[-1] -def _values_equal(a, b): +def _values_equal(a: t.Any, b: t.Any) -> bool: """Check if two values are equal.""" if isinstance(a, Array) and isinstance(b, (Array, list)): if len(a) != len(b): @@ -684,7 +680,7 @@ def _values_equal(a, b): return a == b -def set_key_value(target, key, value, value_type="auto"): +def set_key_value(target: Table | tomlkit.TOMLDocument, key: str, value: t.Any, value_type: str = "auto") -> bool: """Set a key to a value in the target table.""" parent, final_key = navigate_to_key_parent(target, key) converted_value = convert_value(value, value_type) @@ -698,10 +694,10 @@ def set_key_value(target, key, value, value_type="auto"): return True -def remove_key(target, key): +def remove_key(target: Table | tomlkit.TOMLDocument, key: str) -> bool: """Remove a key from the target table.""" parts = key.split(".") - parent = target + parent: Table | InlineTable | tomlkit.TOMLDocument = target for part in parts[:-1]: if part not in parent: return False @@ -723,14 +719,14 @@ def remove_key(target, key): # ============================================================================= -def remove_table(doc, table_path): +def remove_table(doc: tomlkit.TOMLDocument, table_path: str) -> bool: """Remove a table from the document.""" parsed = parse_table_path(table_path) if not parsed: raise TomlFileError("Cannot remove document root") if len(parsed) == 1: - parent = doc + parent: Table | tomlkit.TOMLDocument = doc else: try: parent_path = _format_path(parsed, len(parsed) - 1) @@ -750,8 +746,10 @@ def remove_table(doc, table_path): return True -def _remove_aot_entry(aot, index): +def _remove_aot_entry(aot: AoT, index: int | str) -> bool: """Remove an entry from an array of tables.""" + if index == "append": + raise TomlFileError("Cannot use [append] with state=absent; use [-1] to remove the last entry") if index == -1: if len(aot) > 0: del aot[-1] @@ -768,7 +766,7 @@ def _remove_aot_entry(aot, index): # ============================================================================= -def _init_diff(path, diff_enabled, doc): +def _init_diff(path: str, diff_enabled: bool, doc: tomlkit.TOMLDocument) -> dict[str, str]: """Initialize the diff dictionary.""" diff = { "before": "", @@ -781,7 +779,7 @@ def _init_diff(path, diff_enabled, doc): return diff -def _handle_present(doc, params, original_content): +def _handle_present(doc: tomlkit.TOMLDocument, params: TomlParams, original_content: str) -> tuple[bool, str]: """Handle state=present operations.""" if params.key: if params.value is None: @@ -799,7 +797,7 @@ def _handle_present(doc, params, original_content): return False, "nothing to do" -def _handle_absent(doc, params): +def _handle_absent(doc: tomlkit.TOMLDocument, params: TomlParams) -> tuple[bool, str]: """Handle state=absent operations.""" if params.key: try: @@ -815,7 +813,9 @@ def _handle_absent(doc, params): return False, "nothing to do" -def _write_if_changed(module, target_path, doc, changed, backup): +def _write_if_changed( + module: AnsibleModule, target_path: str, doc: tomlkit.TOMLDocument, changed: bool, backup: bool +) -> str | None: """Write the document to file if changed.""" if not changed or module.check_mode: return None @@ -848,7 +848,7 @@ def _write_if_changed(module, target_path, doc, changed, backup): return backup_file -def do_toml(module, params: TomlParams): +def do_toml(module: AnsibleModule, params: TomlParams) -> tuple[bool, str | None, dict[str, str], str]: """Execute the main TOML file operation.""" target_path = os.path.realpath(params.path) if params.follow and os.path.islink(params.path) else params.path doc = load_toml_document(target_path, params.create) diff --git a/tests/integration/targets/toml_file/tasks/04-array-tables.yml b/tests/integration/targets/toml_file/tasks/04-array-tables.yml index e6c8eb6b3a..76acbb64cf 100644 --- a/tests/integration/targets/toml_file/tasks/04-array-tables.yml +++ b/tests/integration/targets/toml_file/tasks/04-array-tables.yml @@ -8,7 +8,7 @@ - name: test-aot 1 - create first array of tables entry toml_file: path: "{{ output_file }}" - table: products[-1] + table: products[append] key: name value: Hammer register: result_aot_1 @@ -63,7 +63,7 @@ - name: test-aot 3 - append new entry to array of tables toml_file: path: "{{ output_file }}" - table: products[-1] + table: products[append] key: name value: Nail register: result_aot_3 From 7cd91172a4d92ec8f2afdaa406d18a308350e93e Mon Sep 17 00:00:00 2001 From: Jose Drowne Date: Tue, 27 Jan 2026 00:28:41 -0500 Subject: [PATCH 11/17] Fix mypy type narrowing errors in parse_table_path and AoT functions Co-Authored-By: Claude Opus 4.5 --- plugins/modules/toml_file.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/modules/toml_file.py b/plugins/modules/toml_file.py index 8aa1f8809f..4e0784d694 100644 --- a/plugins/modules/toml_file.py +++ b/plugins/modules/toml_file.py @@ -346,6 +346,7 @@ def parse_table_path(table_path: str | None) -> list[tuple[str, int | str | None raise TomlFileError(f"Invalid table path segment: '{segment}'") name = match.group(1) index_str = match.group(2) + index: int | str | None if index_str is None: index = None elif index_str == "append": @@ -602,6 +603,7 @@ def _navigate_aot(aot: AoT, index: int | str | None, is_last: bool, ctx: NavCont return _get_aot_default(aot, ctx, i) if index == "append": return _handle_aot_append(aot, is_last, ctx, i) + assert isinstance(index, int) return _get_aot_at_index(aot, index, ctx, i) @@ -750,6 +752,7 @@ def _remove_aot_entry(aot: AoT, index: int | str) -> bool: """Remove an entry from an array of tables.""" if index == "append": raise TomlFileError("Cannot use [append] with state=absent; use [-1] to remove the last entry") + assert isinstance(index, int) if index == -1: if len(aot) > 0: del aot[-1] From e06e86db64823aacbf6d87f137f686def94ffc9b Mon Sep 17 00:00:00 2001 From: Jose Drowne Date: Tue, 27 Jan 2026 00:32:52 -0500 Subject: [PATCH 12/17] Update version_added to 12.4.0 Co-Authored-By: Claude Opus 4.5 --- plugins/modules/toml_file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/toml_file.py b/plugins/modules/toml_file.py index 4e0784d694..1e6c2ade50 100644 --- a/plugins/modules/toml_file.py +++ b/plugins/modules/toml_file.py @@ -9,7 +9,7 @@ from __future__ import annotations DOCUMENTATION = r""" module: toml_file short_description: Manage individual settings in TOML files -version_added: 12.3.0 +version_added: 12.4.0 extends_documentation_fragment: - ansible.builtin.files - community.general.attributes From c24ef4988ec5806aa6cd39c6fd1cf562dd602e9a Mon Sep 17 00:00:00 2001 From: Jose Drowne Date: Tue, 27 Jan 2026 19:45:47 -0500 Subject: [PATCH 13/17] Fix no-assert sanity errors, use Literal type for value_type, use exception kwarg - Replace assert with t.cast() for mypy type narrowing (no-assert sanity) - Use t.Literal for TomlParams.value_type instead of str - Use exception= instead of traceback= in fail_json calls Co-Authored-By: Claude Opus 4.5 --- plugins/modules/toml_file.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/plugins/modules/toml_file.py b/plugins/modules/toml_file.py index 1e6c2ade50..468e7acb80 100644 --- a/plugins/modules/toml_file.py +++ b/plugins/modules/toml_file.py @@ -312,7 +312,11 @@ class TomlParams: table: str | None key: str | None value: t.Any - value_type: str + value_type: t.Literal[ + "auto", "string", "literal_string", "multiline_string", "multiline_literal_string", + "integer", "hex_integer", "octal_integer", "binary_integer", + "float", "boolean", "datetime", "date", "time", "array", "inline_table", + ] state: str backup: bool create: bool @@ -603,8 +607,7 @@ def _navigate_aot(aot: AoT, index: int | str | None, is_last: bool, ctx: NavCont return _get_aot_default(aot, ctx, i) if index == "append": return _handle_aot_append(aot, is_last, ctx, i) - assert isinstance(index, int) - return _get_aot_at_index(aot, index, ctx, i) + return _get_aot_at_index(aot, t.cast(int, index), ctx, i) def _navigate_existing_segment( @@ -752,16 +755,16 @@ def _remove_aot_entry(aot: AoT, index: int | str) -> bool: """Remove an entry from an array of tables.""" if index == "append": raise TomlFileError("Cannot use [append] with state=absent; use [-1] to remove the last entry") - assert isinstance(index, int) - if index == -1: + int_index = t.cast(int, index) + if int_index == -1: if len(aot) > 0: del aot[-1] return True return False - if 0 <= index < len(aot): - del aot[index] + if 0 <= int_index < len(aot): + del aot[int_index] return True - raise TomlFileError(f"Array of tables index {index} out of range") + raise TomlFileError(f"Array of tables index {int_index} out of range") # ============================================================================= @@ -839,13 +842,13 @@ def _write_if_changed( with os.fdopen(tmpfd, "wb") as f: f.write(encoded_content) except OSError: - module.fail_json(msg="Unable to create temporary file", traceback=traceback.format_exc()) + module.fail_json(msg="Unable to create temporary file", exception=traceback.format_exc()) try: module.atomic_move(tmpfile, os.path.abspath(target_path)) except OSError: module.fail_json( - msg=f"Unable to move temporary file {tmpfile} to {target_path}", traceback=traceback.format_exc() + msg=f"Unable to move temporary file {tmpfile} to {target_path}", exception=traceback.format_exc() ) return backup_file From cc8b32ae2adec8a897f7b102a8f2c5892bd8283f Mon Sep 17 00:00:00 2001 From: Jose Drowne Date: Tue, 27 Jan 2026 19:54:18 -0500 Subject: [PATCH 14/17] Use Literal type for TomlParams.state field Co-Authored-By: Claude Opus 4.5 --- plugins/modules/toml_file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/toml_file.py b/plugins/modules/toml_file.py index 468e7acb80..40d9613580 100644 --- a/plugins/modules/toml_file.py +++ b/plugins/modules/toml_file.py @@ -317,7 +317,7 @@ class TomlParams: "integer", "hex_integer", "octal_integer", "binary_integer", "float", "boolean", "datetime", "date", "time", "array", "inline_table", ] - state: str + state: t.Literal["absent", "present"] backup: bool create: bool follow: bool From 4c1d5c047024720e4586e172d9798b1ee33782fe Mon Sep 17 00:00:00 2001 From: Jose Drowne Date: Wed, 28 Jan 2026 19:00:25 -0500 Subject: [PATCH 15/17] Use IndexType alias with Literal["append"] for proper mypy narrowing - Define IndexType = int | t.Literal["append"] | None type alias - Replace all int | str | None annotations with IndexType - Remove t.cast() calls - mypy now narrows after "append" check - Ruff reformatted value_type Literal (one item per line) Co-Authored-By: Claude Opus 4.5 --- plugins/modules/toml_file.py | 50 ++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/plugins/modules/toml_file.py b/plugins/modules/toml_file.py index 40d9613580..2d077afa01 100644 --- a/plugins/modules/toml_file.py +++ b/plugins/modules/toml_file.py @@ -304,6 +304,10 @@ class TomlFileError(Exception): pass +# Type alias for array-of-tables index (int for position, "append" for new entry, None for default) +IndexType = int | t.Literal["append"] | None + + @dataclass class TomlParams: """Parameters for TOML file operations.""" @@ -313,9 +317,22 @@ class TomlParams: key: str | None value: t.Any value_type: t.Literal[ - "auto", "string", "literal_string", "multiline_string", "multiline_literal_string", - "integer", "hex_integer", "octal_integer", "binary_integer", - "float", "boolean", "datetime", "date", "time", "array", "inline_table", + "auto", + "string", + "literal_string", + "multiline_string", + "multiline_literal_string", + "integer", + "hex_integer", + "octal_integer", + "binary_integer", + "float", + "boolean", + "datetime", + "date", + "time", + "array", + "inline_table", ] state: t.Literal["absent", "present"] backup: bool @@ -327,7 +344,7 @@ class TomlParams: class NavContext: """Context for table navigation operations.""" - parsed: list[tuple[str, int | str | None]] + parsed: list[tuple[str, IndexType]] create: bool @@ -336,7 +353,7 @@ class NavContext: # ============================================================================= -def parse_table_path(table_path: str | None) -> list[tuple[str, int | str | None]]: +def parse_table_path(table_path: str | None) -> list[tuple[str, IndexType]]: """Parse a table path with optional array indices.""" if table_path is None or table_path == "": return [] # Empty list means document root @@ -350,7 +367,7 @@ def parse_table_path(table_path: str | None) -> list[tuple[str, int | str | None raise TomlFileError(f"Invalid table path segment: '{segment}'") name = match.group(1) index_str = match.group(2) - index: int | str | None + index: IndexType if index_str is None: index = None elif index_str == "append": @@ -362,7 +379,7 @@ def parse_table_path(table_path: str | None) -> list[tuple[str, int | str | None return result -def _format_path(parsed: list[tuple[str, int | str | None]], up_to: int | None = None) -> str: +def _format_path(parsed: list[tuple[str, IndexType]], up_to: int | None = None) -> str: """Format a parsed path back to string for error messages.""" if up_to is None: up_to = len(parsed) @@ -563,7 +580,7 @@ def _create_regular_table(current: Table | tomlkit.TOMLDocument, name: str) -> T def _create_table_segment( - current: Table | tomlkit.TOMLDocument, name: str, index: int | str | None, ctx: NavContext, i: int + current: Table | tomlkit.TOMLDocument, name: str, index: IndexType, ctx: NavContext, i: int ) -> Table: """Create a new table segment when name not in current.""" if not ctx.create: @@ -601,17 +618,17 @@ def _handle_aot_append(aot: AoT, is_last: bool, ctx: NavContext, i: int) -> Tabl return new_table -def _navigate_aot(aot: AoT, index: int | str | None, is_last: bool, ctx: NavContext, i: int) -> Table: +def _navigate_aot(aot: AoT, index: IndexType, is_last: bool, ctx: NavContext, i: int) -> Table: """Navigate within an array of tables.""" if index is None: return _get_aot_default(aot, ctx, i) if index == "append": return _handle_aot_append(aot, is_last, ctx, i) - return _get_aot_at_index(aot, t.cast(int, index), ctx, i) + return _get_aot_at_index(aot, index, ctx, i) def _navigate_existing_segment( - current: Table | tomlkit.TOMLDocument, name: str, index: int | str | None, ctx: NavContext, i: int + current: Table | tomlkit.TOMLDocument, name: str, index: IndexType, ctx: NavContext, i: int ) -> Table | tomlkit.TOMLDocument: """Navigate into an existing table segment.""" item = current[name] @@ -751,20 +768,19 @@ def remove_table(doc: tomlkit.TOMLDocument, table_path: str) -> bool: return True -def _remove_aot_entry(aot: AoT, index: int | str) -> bool: +def _remove_aot_entry(aot: AoT, index: int | t.Literal["append"]) -> bool: """Remove an entry from an array of tables.""" if index == "append": raise TomlFileError("Cannot use [append] with state=absent; use [-1] to remove the last entry") - int_index = t.cast(int, index) - if int_index == -1: + if index == -1: if len(aot) > 0: del aot[-1] return True return False - if 0 <= int_index < len(aot): - del aot[int_index] + if 0 <= index < len(aot): + del aot[index] return True - raise TomlFileError(f"Array of tables index {int_index} out of range") + raise TomlFileError(f"Array of tables index {index} out of range") # ============================================================================= From 1ec4538a80551346394f5e5b84283cbbaab811db Mon Sep 17 00:00:00 2001 From: Jose Drowne Date: Wed, 28 Jan 2026 19:38:25 -0500 Subject: [PATCH 16/17] Fix Python 3.8/3.9 compatibility for IndexType alias Use t.Union instead of | operator for type alias, as | requires Python 3.10+. The | operator works in annotations with `from __future__ import annotations` but not in runtime expressions like type alias assignments. Co-Authored-By: Claude Opus 4.5 --- plugins/modules/toml_file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/toml_file.py b/plugins/modules/toml_file.py index 2d077afa01..d9124b8016 100644 --- a/plugins/modules/toml_file.py +++ b/plugins/modules/toml_file.py @@ -305,7 +305,7 @@ class TomlFileError(Exception): # Type alias for array-of-tables index (int for position, "append" for new entry, None for default) -IndexType = int | t.Literal["append"] | None +IndexType = t.Union[int, t.Literal["append"], None] # noqa: UP007 (Python 3.8/3.9 compat) @dataclass From fe89b60b80a304a81643b82b734d89341cb116df Mon Sep 17 00:00:00 2001 From: Jose Drowne Date: Sat, 31 Jan 2026 01:12:50 -0500 Subject: [PATCH 17/17] Move IndexType alias inside TYPE_CHECKING block Co-Authored-By: Claude Opus 4.5 --- plugins/modules/toml_file.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/modules/toml_file.py b/plugins/modules/toml_file.py index d9124b8016..939972e77b 100644 --- a/plugins/modules/toml_file.py +++ b/plugins/modules/toml_file.py @@ -304,8 +304,9 @@ class TomlFileError(Exception): pass -# Type alias for array-of-tables index (int for position, "append" for new entry, None for default) -IndexType = t.Union[int, t.Literal["append"], None] # noqa: UP007 (Python 3.8/3.9 compat) +if t.TYPE_CHECKING: + # Type alias for array-of-tables index (int for position, "append" for new entry, None for default) + IndexType = t.Union[int, t.Literal["append"], None] # noqa: UP007 @dataclass