feat(macdefaults): Add support for copmplex keys and nested dictionaries

This commit is contained in:
Carlos Álvaro 2024-06-27 11:41:58 +02:00 committed by Daniel Wozniak
parent 7c5baf4bbe
commit f5067e7853
4 changed files with 1067 additions and 451 deletions

View file

@ -1,18 +1,23 @@
"""
Set defaults settings on macOS.
This module uses defaults cli under the hood to read and write defaults on macOS.
This module uses defaults cli under the hood to import and export defaults on macOS.
Thus, the module is limited to the capabilities of the defaults command.
However, it uses the plistlib package to handle the conversion between the defaults
output and Python dictionaries. It is also used to create the plist files to import
the defaults.
Read macOS defaults help page for more information on defaults command.
Read plistlib documentation for more information on how the conversion is done:
https://docs.python.org/3/library/plistlib.html
"""
import logging
import plistlib
import re
import tempfile
from datetime import datetime
import salt.utils.data
import salt.utils.platform
import salt.utils.versions
from salt.exceptions import CommandExecutionError
@ -31,34 +36,55 @@ def __virtual__():
return False
def write(domain, key, value, vtype=None, user=None, type=None):
def write(
domain,
key,
value,
vtype=None,
user=None,
dict_merge=False,
array_add=False,
type=None,
):
"""
Write a default to the system
Limitations:
- There is no multi-level support for arrays and dictionaries
- Internal values types for arrays and dictionaries cannot be specified
CLI Example:
.. code-block:: bash
salt '*' macdefaults.write com.apple.CrashReporter DialogType Server
salt '*' macdefaults.write com.apple.Finder DownloadsFolderListViewSettingsVersion 1
salt '*' macdefaults.write NSGlobalDomain ApplePersistence True vtype=bool
salt '*' macdefaults.write com.apple.Finder ComputerViewSettings.CustomViewStyle "icnv"
salt '*' macdefaults.write com.apple.Dock lastShowIndicatorTime 737720347.089987 vtype=date
domain
The name of the domain to write to
key
The key of the given domain to write to
The key of the given domain to write to.
It can be a nested key/index separated by dots.
value
The value to write to the given key
The value to write to the given key.
Dates should be in the format 'YYYY-MM-DDTHH:MM:SSZ'
vtype
The type of value to be written, valid types are string, data, int[eger],
float, bool[ean], date, array, array-add, dict, dict-add
The type of value to be written, valid types are string, int[eger],
float, bool[ean], date and data.
dict and array are also valid types but are only used for validation.
dict-add and array-add are supported for backward compatibility.
However, their corresponding sibling options dict_merge and array_add
are recommended.
This parameter is optional. It will be used to cast the values to the
specified type before writing them to the system. If not provided, the
type will be inferred from the value.
Useful when writing values such as dates or binary data.
type
Deprecated! Use vtype instead
@ -68,6 +94,20 @@ def write(domain, key, value, vtype=None, user=None, type=None):
user
The user to write the defaults to
dict_merge
Merge the value into the existing dictionary.
If current value is not a dictionary this option will be ignored.
This option will be set to True if vtype is dict-add.
array_add
Append the value to the array.
If current value is not a list this option will be ignored.
This option will be set to True if vtype is array-add.
Raises:
KeyError: When the key is not found in the domain
IndexError: When the key is not a valid array index
"""
if type is not None:
salt.utils.versions.warn_until(
@ -81,25 +121,44 @@ def write(domain, key, value, vtype=None, user=None, type=None):
"The 'vtype' argument in macdefaults.write takes precedence over 'type'."
)
if vtype is None:
vtype = "string"
plist = _load_plist(domain, user=user) or {}
keys = key.split(".")
last_key = keys[-1]
if vtype in ("bool", "boolean"):
value = _convert_to_defaults_boolean(value)
# Traverse the plist
container = _traverse_keys(plist, keys[:-1])
if container is None:
raise KeyError(f"Key not found: {key} for domain: {domain}")
if isinstance(value, dict):
value = list((k, v) for k, v in value.items())
value = salt.utils.data.flatten(value)
elif isinstance(value, (int, float, bool, str)):
value = [value]
elif not isinstance(value, list):
raise ValueError("Value must be a list, dict, int, float, bool, or string")
current_value = None
if isinstance(container, dict):
current_value = container.get(last_key)
elif isinstance(container, list) and last_key.isdigit():
last_key = int(last_key)
if -len(container) <= last_key < len(container):
current_value = container[last_key]
else:
raise IndexError(f"Index {last_key} is out of range for domain: {domain}")
# Quote values that are neither integers nor floats
value = map(lambda v: str(v) if isinstance(v, (int, float)) else f'"{v}"', value)
# Write/Update the new value
if vtype is not None:
if vtype == "array-add":
array_add = True
elif vtype == "dict-add":
dict_merge = True
value = cast_value_to_vtype(value, vtype)
cmd = f'write "{domain}" "{key}" -{vtype} {" ".join(value)}'
return _run_defaults_cmd(cmd, runas=user)
if isinstance(current_value, dict) and isinstance(value, dict) and dict_merge:
container[last_key].update(value)
elif isinstance(current_value, list) and array_add:
if isinstance(value, list):
container[last_key].extend(value)
else:
container[last_key].append(value)
else:
container[last_key] = value
return _save_plist(domain, plist, user=user)
def read(domain, key, user=None):
@ -110,7 +169,7 @@ def read(domain, key, user=None):
.. code-block:: bash
salt '*' macdefaults.read com.apple.CrashReporter DialogType
salt '*' macdefaults.read com.apple.Dock persistent-apps.1.title-data.file-label
salt '*' macdefaults.read NSGlobalDomain ApplePersistence
@ -118,27 +177,21 @@ def read(domain, key, user=None):
The name of the domain to read from
key
The key of the given domain to read from
The key of the given domain to read from.
It can be a nested key/index separated by dots.
user
The user to read the defaults as
The user to read the defaults from
Returns:
The current value for the given key, or None if the key does not exist.
"""
cmd = f'read "{domain}" "{key}"'
ret = _run_defaults_cmd(cmd, runas=user)
plist = _load_plist(domain, user)
if plist is None:
return None
if ret["retcode"] != 0:
if "does not exist" in ret["stderr"]:
return None
raise CommandExecutionError(f"Failed to read default: {ret['stderr']}")
# Type cast the value
try:
vtype = read_type(domain, key, user)
except CommandExecutionError:
vtype = None
return _default_to_python(ret["stdout"].strip(), vtype)
return _traverse_keys(plist, key.split("."))
def delete(domain, key, user=None):
@ -157,165 +210,195 @@ def delete(domain, key, user=None):
The name of the domain to delete from
key
The key of the given domain to delete
The key of the given domain to delete.
It can be a nested key separated by dots.
user
The user to delete the defaults with
"""
cmd = f'delete "{domain}" "{key}"'
return _run_defaults_cmd(cmd, runas=user)
plist = _load_plist(domain, user=user)
if plist is None:
return None
keys = key.split(".")
# Traverse the plist til the penultimate key.
# Last key must be handled separately since we
# need the parent dictionary to delete that key.
target = _traverse_keys(plist, keys[:-1])
if target is None:
return None
# Delete the last key if it exists and update defaults
last_key = keys[-1]
key_in_plist = False
if isinstance(target, dict) and last_key in target:
key_in_plist = True
elif (
isinstance(target, list)
and last_key.isdigit()
and -len(target) <= int(last_key) < len(target)
):
key_in_plist = True
last_key = int(last_key)
if not key_in_plist:
return None
del target[last_key]
return _save_plist(domain, plist, user=user)
def read_type(domain, key, user=None):
def cast_value_to_vtype(value, vtype):
"""
Read a default type from the system
If the key is not found, None is returned.
Convert the value to the specified vtype.
If the value cannot be converted, it will be returned as is.
CLI Example:
value
The value to be converted
.. code-block:: bash
vtype
The type to convert the value to
salt '*' macdefaults.read_type com.apple.CrashReporter DialogType
Returns:
The converted value
salt '*' macdefaults.read_type NSGlobalDomain ApplePersistence
"""
# Boolean
if vtype in ("bool", "boolean"):
if isinstance(value, str):
if value.lower() in ("true", "yes", "1"):
value = True
elif value.lower() in ("false", "no", "0"):
value = False
else:
raise ValueError(f"Invalid value for boolean: '{value}'")
elif value in (0, 1):
value = bool(value)
elif not isinstance(value, bool):
raise ValueError(f"Invalid value for boolean: '{value}'")
# String
elif vtype == "string":
if isinstance(value, bool):
value = "YES" if value else "NO"
elif isinstance(value, (int, float)):
value = str(value)
elif isinstance(value, datetime):
value = value.strftime("%Y-%m-%dT%H:%M:%SZ")
elif isinstance(value, bytes):
value = value.decode()
# Integer
elif vtype in ("int", "integer"):
value = int(value)
# Float
elif vtype == "float":
value = float(value)
# Date
elif vtype == "date":
if not isinstance(value, datetime):
try:
value = datetime.fromtimestamp(float(value))
except ValueError:
if re.match(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", value):
value = datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
else:
raise ValueError(f"Invalid date format: '{value}'")
# Data
elif vtype == "data":
if isinstance(value, str):
value = value.encode()
elif not isinstance(value, bytes):
raise ValueError(f"Invalid value for data: '{value}'")
# Dictionary
elif vtype in ("dict", "dict-add"):
if not isinstance(value, dict):
raise ValueError(f"Invalid value for dictionary: '{value}'")
# Array
elif vtype in ("array", "array-add"):
if not isinstance(value, list):
raise ValueError(f"Invalid value for array: '{value}'")
else:
raise ValueError(f"Invalid type: '{vtype}'")
return value
def _load_plist(domain, user=None):
"""
Load a plist from the system and return it as a dictionary
domain
The name of the domain to read from
key
The key of the given domain to read the type of
user
The user to read the defaults as
The user to read the defaults as. Defaults to root (None).
Raises:
CommandExecutionError: When the defaults command fails
Other exceptions thrown by plistlib.loads
Returns:
A dictionary with the plist contents, or None if the domain does not exist.
"""
cmd = f'read-type "{domain}" "{key}"'
cmd = f'export "{domain}" -'
ret = _run_defaults_cmd(cmd, runas=user)
if ret["retcode"] != 0:
if "does not exist" in ret["stderr"]:
raise CommandExecutionError(f"Failed to export defaults: {ret['stderr']}")
plist = plistlib.loads(ret["stdout"].encode())
if not plist:
return None
return plist
def _save_plist(domain, plist, user=None):
"""
Save a plist dictionary to the system
domain
The name of the domain to read from
plist
The dictionary to export as a plist
user
The user to export the defaults to. Defaults to root (None).
Raises:
CommandExecutionError: When the defaults command fails
Other exceptions thrown by plistlib.dump
Returns:
A dictionary with the defaults command result
"""
with tempfile.NamedTemporaryFile() as tmpfile:
plistlib.dump(plist, tmpfile)
tmpfile.flush()
cmd = f'import "{domain}" "{tmpfile.name}"'
return _run_defaults_cmd(cmd, runas=user)
def _traverse_keys(plist, keys):
value = plist
for k in keys:
if isinstance(value, dict):
value = value.get(k)
elif (
isinstance(value, list)
and k.isdigit()
and -len(value) <= int(k) < len(value)
):
value = value[int(k)]
else:
value = None
if value is None:
return None
raise CommandExecutionError(f"Failed to read type: {ret['stderr']}")
return re.sub(r"^Type is ", "", ret["stdout"].strip())
def _default_to_python(value, vtype=None):
"""
Cast the value returned by the defaults command in vytpe to Python type
value
The value to cast
vtype
The type to cast the value to
"""
if vtype in ["integer", "int"]:
return int(value)
if vtype == "float":
return float(value)
if vtype in ["boolean", "bool"]:
return value in ["1", "TRUE", "YES"]
if vtype == "array":
return _parse_defaults_array(value)
if vtype in ["dict", "dictionary"]:
return _parse_defaults_dict(value)
return value
def _parse_defaults_array(value):
"""
Parse an array from a string returned by `defaults read`
and returns the array content as a list
value
A multiline string with the array content, including the surrounding parenthesis
"""
lines = value.splitlines()
if not re.match(r"\s*\(", lines[0]) or not re.match(r"\s*\)", lines[-1]):
raise ValueError("Invalid array format")
lines = lines[1:-1]
# Remove leading and trailing spaces
lines = list(map(lambda line: line.strip(), lines))
# Remove trailing commas
lines = list(map(lambda line: re.sub(r",?$", "", line), lines))
# Remove quotes
lines = list(map(lambda line: line.strip('"'), lines))
# Convert to numbers if possible
lines = list(map(_convert_to_number_if_possible, lines))
return lines
def _parse_defaults_dict(value):
"""
Parse a dictionary from a string returned by `defaults read`
and returns the dictionary content as a Python dictionary
value (str):
A multiline string with the dictionary content, including the surrounding curly braces
"""
lines = value.splitlines()
if not re.match(r"\s*\{", lines[0]) or not re.match(r"\s*\}", lines[-1]):
raise ValueError("Invalid dictionary format")
contents = {}
lines = list(map(lambda line: line.strip(), lines[1:-1]))
for line in lines:
key, value = re.split(r"\s*=\s*", line.strip())
if re.match(r"\s*(\(|\{)", value):
raise ValueError("Nested arrays and dictionaries are not supported")
value = re.sub(r";?$", "", value)
contents[key] = _convert_to_number_if_possible(value.strip('"'))
return contents
def _convert_to_number_if_possible(value):
"""
Convert a string to a number if possible
value
The string to convert
"""
try:
return int(value)
except ValueError:
try:
return float(value)
except ValueError:
return value
def _convert_to_defaults_boolean(value):
"""
Convert a boolean to a string that can be used with the defaults command
value
The boolean value to convert
"""
if value in (True, 1):
return "TRUE"
if value in (False, 0):
return "FALSE"
BOOLEAN_ALLOWED_VALUES = ["TRUE", "YES", "FALSE", "NO"]
if value not in BOOLEAN_ALLOWED_VALUES:
msg = "Value must be a boolean or a string of "
msg += ", ".join(BOOLEAN_ALLOWED_VALUES)
raise ValueError(msg)
return value

View file

@ -18,12 +18,13 @@ def __virtual__():
return (False, "Only supported on macOS")
def write(name, domain, value, vtype="string", user=None):
def write(name, domain, value, vtype=None, user=None):
"""
Write a default to the system
name
The key of the given domain to write to
The key of the given domain to write to.
It can be a nested key/index separated by dots.
domain
The name of the domain to write to
@ -42,7 +43,9 @@ def write(name, domain, value, vtype="string", user=None):
ret = {"name": name, "result": True, "comment": "", "changes": {}}
current_value = __salt__["macdefaults.read"](domain, name, user)
value = _cast_value(value, vtype)
if vtype is not None:
value = __salt__["macdefaults.cast_value_to_vtype"](value, vtype)
if _compare_values(value, current_value, vtype):
ret["comment"] += f"{domain} {name} is already set to {value}"
@ -62,7 +65,8 @@ def absent(name, domain, user=None):
Make sure the defaults value is absent
name
The key of the given domain to remove
The key of the given domain to remove.
It can be a nested key/index separated by dots.
domain
The name of the domain to remove from
@ -75,7 +79,7 @@ def absent(name, domain, user=None):
out = __salt__["macdefaults.delete"](domain, name, user)
if out["retcode"] != 0:
if out is None or out["retcode"] != 0:
ret["comment"] += f"{domain} {name} is already absent"
else:
ret["changes"]["absent"] = f"{domain} {name} is now absent"
@ -98,81 +102,11 @@ def _compare_values(new, current, vtype):
"""
if vtype == "array-add":
return _is_subarray(new, current)
if isinstance(new, list):
return new == current[-len(new) :]
return new == current[-1]
if vtype == "dict-add":
return all(key in current and new[key] == current[key] for key in new.keys())
return new == current
def _is_subarray(new, current):
"""
Check if new is a subarray of current array.
This method does not check only whether all elements in new array
are present in current array, but also whether the elements are in
the same order.
new
The new array to compare
current
The current array to compare
"""
current_len = len(current)
new_len = len(new)
if new_len == 0:
return True
if new_len > current_len:
return False
for i in range(current_len - new_len + 1):
# Check if the new array is found at this position
if current[i : i + new_len] == new:
return True
return False
def _cast_value(value, vtype):
"""
Cast the given macOS default value to Python type
value
The value to cast from macOS default
vtype
The type to cast the value from
"""
def safe_cast(val, to_type, default=None):
"""
Auxiliary function to safely cast a value to a given type
"""
try:
return to_type(val)
except ValueError:
return default
if vtype in ("bool", "boolean"):
if value not in [True, 1, "TRUE", "YES", False, 0, "FALSE", "NO"]:
raise ValueError(f"Invalid value for boolean: {value}")
return value in [True, "TRUE", "YES"]
if vtype in ("int", "integer"):
return safe_cast(value, int)
if vtype == "float":
return safe_cast(value, float)
if vtype in ("dict", "dict-add"):
return safe_cast(value, dict)
if vtype in ["array", "array-add"]:
return safe_cast(value, list)
return value

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,19 @@
import pytest
import salt.modules.macdefaults as macdefaults_module
import salt.states.macdefaults as macdefaults
from tests.support.mock import MagicMock, patch
@pytest.fixture
def configure_loader_modules():
return {macdefaults: {}}
return {
macdefaults: {
"__salt__": {
"macdefaults.cast_value_to_vtype": macdefaults_module.cast_value_to_vtype
}
}
}
def test_write_default():
@ -29,7 +36,7 @@ def test_write_default():
out = macdefaults.write("DialogType", "com.apple.CrashReporter", "Server")
read_mock.assert_called_once_with("com.apple.CrashReporter", "DialogType", None)
write_mock.assert_called_once_with(
"com.apple.CrashReporter", "DialogType", "Server", "string", None
"com.apple.CrashReporter", "DialogType", "Server", None, None
)
assert out == expected
@ -74,7 +81,7 @@ def test_write_default_boolean():
macdefaults.__salt__,
{"macdefaults.read": read_mock, "macdefaults.write": write_mock},
):
out = macdefaults.write("Key", "com.apple.something", True, vtype="boolean")
out = macdefaults.write("Key", "com.apple.something", "YES", vtype="boolean")
read_mock.assert_called_once_with("com.apple.something", "Key", None)
write_mock.assert_called_once_with(
"com.apple.something", "Key", True, "boolean", None
@ -122,10 +129,10 @@ def test_write_default_integer():
macdefaults.__salt__,
{"macdefaults.read": read_mock, "macdefaults.write": write_mock},
):
out = macdefaults.write("Key", "com.apple.something", 1337, vtype="integer")
out = macdefaults.write("Key", "com.apple.something", 1337)
read_mock.assert_called_once_with("com.apple.something", "Key", None)
write_mock.assert_called_once_with(
"com.apple.something", "Key", 1337, "integer", None
"com.apple.something", "Key", 1337, None, None
)
assert out == expected
@ -170,7 +177,7 @@ def test_write_default_float():
macdefaults.__salt__,
{"macdefaults.read": read_mock, "macdefaults.write": write_mock},
):
out = macdefaults.write("Key", "com.apple.something", 0.865, vtype="float")
out = macdefaults.write("Key", "com.apple.something", "0.8650", vtype="float")
read_mock.assert_called_once_with("com.apple.something", "Key", None)
write_mock.assert_called_once_with(
"com.apple.something", "Key", 0.865, "float", None
@ -195,7 +202,7 @@ def test_write_default_float_already_set():
macdefaults.__salt__,
{"macdefaults.read": read_mock, "macdefaults.write": write_mock},
):
out = macdefaults.write("Key", "com.apple.something", 0.86500, vtype="float")
out = macdefaults.write("Key", "com.apple.something", 0.86500)
read_mock.assert_called_once_with("com.apple.something", "Key", None)
assert not write_mock.called
assert out == expected
@ -245,7 +252,7 @@ def test_write_default_array_already_set():
macdefaults.__salt__,
{"macdefaults.read": read_mock, "macdefaults.write": write_mock},
):
out = macdefaults.write("Key", "com.apple.something", value, vtype="array")
out = macdefaults.write("Key", "com.apple.something", value)
read_mock.assert_called_once_with("com.apple.something", "Key", None)
assert not write_mock.called
assert out == expected
@ -283,9 +290,9 @@ def test_write_default_array_add():
def test_write_default_array_add_already_set_distinct_order():
"""
Test writing a default setting adding an array to another that is already set
The new array is in a different order than the existing one
The new array is in a different order than the last elements of the existing one
"""
write_value = ["a", 1]
write_value = [2, "a"]
read_value = ["b", 1, "a", 2]
expected = {
"changes": {"written": f"com.apple.something Key is set to {write_value}"},
@ -315,7 +322,7 @@ def test_write_default_array_add_already_set_same_order():
Test writing a default setting adding an array to another that is already set
The new array is already in the same order as the existing one
"""
write_value = ["a", 1]
write_value = [1, 2]
read_value = ["b", "a", 1, 2]
expected = {
"changes": {},
@ -455,7 +462,7 @@ def test_absent_default_already():
"result": True,
}
mock = MagicMock(return_value={"retcode": 1})
mock = MagicMock(return_value=None)
with patch.dict(macdefaults.__salt__, {"macdefaults.delete": mock}):
out = macdefaults.absent("Key", "com.apple.something")
mock.assert_called_once_with("com.apple.something", "Key", None)