feat(macdefaults): Add basic support for array-add and dict-add

This commit is contained in:
Carlos Álvaro 2024-05-08 11:14:55 +02:00 committed by Daniel Wozniak
parent 6e51f3b755
commit aaba6250c0
4 changed files with 437 additions and 108 deletions

View file

@ -1,11 +1,11 @@
"""
Set defaults on macOS.
Set defaults settings on macOS.
This module uses defaults cli under the hood to read and write defaults on macOS.
So the module is limited to the capabilities of the defaults command.
Thus, the module is limited to the capabilities of the defaults command.
Read macOS defaults help page for more information on the defaults command.
Read macOS defaults help page for more information on defaults command.
"""
@ -33,11 +33,11 @@ def __virtual__():
def write(domain, key, value, vtype=None, user=None, type=None):
"""
Write a default to the system.
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.
- There is no multi-level support for arrays and dictionaries
- Internal values types for arrays and dictionaries cannot be specified
CLI Example:
@ -45,28 +45,28 @@ def write(domain, key, value, vtype=None, user=None, type=None):
salt '*' macdefaults.write com.apple.CrashReporter DialogType Server
salt '*' macdefaults.write NSGlobalDomain ApplePersistence True type=bool
salt '*' macdefaults.write NSGlobalDomain ApplePersistence True vtype=bool
domain
The name of the domain to write to.
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
value
The value to write to the given key.
The value to write to the given key
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
type
Deprecated! Use vtype instead.
type collides with Python's built-in type() function.
This parameter will be removed in 3009.
Deprecated! Use vtype instead
type collides with Python's built-in type() function
This parameter will be removed in 3009
user
The user to write the defaults to.
The user to write the defaults to
"""
if type is not None:
@ -95,7 +95,7 @@ def write(domain, key, value, vtype=None, user=None, type=None):
elif not isinstance(value, list):
raise ValueError("Value must be a list, dict, int, float, bool, or string")
# Quote values that are not integers or floats
# Quote values that are neither integers nor floats
value = map(lambda v: str(v) if isinstance(v, (int, float)) else f'"{v}"', value)
cmd = f'write "{domain}" "{key}" -{vtype} {" ".join(value)}'
@ -169,25 +169,25 @@ def delete(domain, key, user=None):
def read_type(domain, key, user=None):
"""
Read the type of the given type.
If the given key is not found, then return None.
Read a default type from the system
If the key is not found, None is returned.
CLI Example:
.. code-block:: bash
salt '*' macdefaults.read-type com.apple.CrashReporter DialogType
salt '*' macdefaults.read_type com.apple.CrashReporter DialogType
salt '*' macdefaults.read_type NSGlobalDomain ApplePersistence
domain
The name of the domain to read from.
The name of the domain to read from
key
The key of the given domain to read the type of.
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
"""
cmd = f'read-type "{domain}" "{key}"'
@ -203,23 +203,13 @@ def read_type(domain, key, user=None):
def _default_to_python(value, vtype=None):
"""
Cast a value returned by defaults in vytpe to Python type.
CLI Example:
.. code-block:: bash
salt '*' macdefaults.cast_value_to_type "1" int
salt '*' macdefaults.cast_value_to_type "1.0" float
salt '*' macdefaults.cast_value_to_type "TRUE" bool
Cast the value returned by the defaults command in vytpe to Python type
value
The value to cast.
The value to cast
vtype
The type to cast the value to.
The type to cast the value to
"""
if vtype in ["integer", "int"]:
@ -238,10 +228,10 @@ def _default_to_python(value, vtype=None):
def _parse_defaults_array(value):
"""
Parse an array from a string returned by `defaults read`
and returns the array content as a list.
and returns the array content as a list
value
A multiline string with the array content, including the surrounding parenthesis.
A multiline string with the array content, including the surrounding parenthesis
"""
lines = value.splitlines()
@ -268,12 +258,11 @@ def _parse_defaults_array(value):
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.
A multiline string with the dictionary content, including the surrounding curly braces
Returns:
dict: The dictionary content as a Python dictionary.
"""
lines = value.splitlines()
if not re.match(r"\s*\{", lines[0]) or not re.match(r"\s*\}", lines[-1]):
@ -294,10 +283,10 @@ def _parse_defaults_dict(value):
def _convert_to_number_if_possible(value):
"""
Convert a string to a number if possible.
Convert a string to a number if possible
value
The string to convert.
The string to convert
"""
try:
@ -311,15 +300,15 @@ def _convert_to_number_if_possible(value):
def _convert_to_defaults_boolean(value):
"""
Convert a boolean to a string that can be used with the defaults command.
Convert a boolean to a string that can be used with the defaults command
value
The boolean value to convert.
The boolean value to convert
"""
if value is True:
if value in (True, 1):
return "TRUE"
if value is False:
if value in (False, 0):
return "FALSE"
BOOLEAN_ALLOWED_VALUES = ["TRUE", "YES", "FALSE", "NO"]
@ -333,14 +322,14 @@ def _convert_to_defaults_boolean(value):
def _run_defaults_cmd(action, runas=None):
"""
Run a 'defaults' command.
Run the 'defaults' command with the given action
action
The action to perform with all of its parameters.
The action to perform with all of its parameters
Example: 'write com.apple.CrashReporter DialogType "Server"'
runas
The user to run the command as.
The user to run the command as
"""
ret = __salt__["cmd.run_all"](f"defaults {action}", runas=runas)
@ -354,10 +343,10 @@ def _run_defaults_cmd(action, runas=None):
def _remove_timestamp(text):
"""
Remove the timestamp from the output of the defaults command.
Remove the timestamp from the output of the defaults command if found
text
The text to remove the timestamp from.
The text to remove the timestamp from
"""
pattern = r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d{3})?\s+defaults\[\d+\:\d+\]"

View file

@ -4,13 +4,8 @@ Writing/reading defaults from a macOS minion
"""
import logging
import re
import salt.utils.platform
log = logging.getLogger(__name__)
__virtualname__ = "macdefaults"
@ -43,14 +38,13 @@ def write(name, domain, value, vtype="string", user=None):
user
The user to write the defaults to
"""
ret = {"name": name, "result": True, "comment": "", "changes": {}}
current_value = __salt__["macdefaults.read"](domain, name, user)
value = _cast_value(value, vtype)
if _compare_values(value, current_value, strict=re.match(r"-add$", vtype) is None):
if _compare_values(value, current_value, vtype):
ret["comment"] += f"{domain} {name} is already set to {value}"
else:
out = __salt__["macdefaults.write"](domain, name, value, vtype, user)
@ -76,7 +70,6 @@ def absent(name, domain, user=None):
user
The user to write the defaults to
"""
ret = {"name": name, "result": True, "comment": "", "changes": {}}
@ -90,9 +83,9 @@ def absent(name, domain, user=None):
return ret
def _compare_values(new, current, strict=True):
def _compare_values(new, current, vtype):
"""
Compare two values
Compare two values based on their type
new
The new value to compare
@ -100,24 +93,73 @@ def _compare_values(new, current, strict=True):
current
The current value to compare
strict
If True, the values must be exactly the same, if False, the new value
must be in the current value
vtype
The type of default value to be compared
"""
if strict:
return new == current
return new in current
if vtype == "array-add":
return _is_subarray(new, current)
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, "TRUE", "YES", False, "FALSE", "NO"]:
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"]

View file

@ -52,7 +52,7 @@ def test_write_default():
)
def test_write_with_user():
def test_write_default_with_user():
"""
Test writing a default setting with a specific user
"""
@ -67,9 +67,9 @@ def test_write_with_user():
)
def test_write_true_boolean():
def test_write_default_true_boolean():
"""
Test writing a True boolean setting
Test writing a default True boolean setting
"""
mock = MagicMock(return_value={"retcode": 0})
with patch("salt.modules.macdefaults._run_defaults_cmd", mock):
@ -80,9 +80,9 @@ def test_write_true_boolean():
)
def test_write_false_bool():
def test_write_default_false_bool():
"""
Test writing a False boolean setting
Test writing a default False boolean setting
"""
mock = MagicMock(return_value={"retcode": 0})
with patch("salt.modules.macdefaults._run_defaults_cmd", mock):
@ -93,9 +93,9 @@ def test_write_false_bool():
)
def test_write_int():
def test_write_default_int():
"""
Test writing an int setting
Test writing a default int setting
"""
mock = MagicMock(return_value={"retcode": 0})
with patch("salt.modules.macdefaults._run_defaults_cmd", mock):
@ -106,9 +106,9 @@ def test_write_int():
)
def test_write_integer():
def test_write_default_integer():
"""
Test writing an integer setting
Test writing a default integer setting
"""
mock = MagicMock(return_value={"retcode": 0})
with patch("salt.modules.macdefaults._run_defaults_cmd", mock):
@ -119,9 +119,9 @@ def test_write_integer():
)
def test_write_float():
def test_write_default_float():
"""
Test writing a float setting
Test writing a default float setting
"""
mock = MagicMock(return_value={"retcode": 0})
with patch("salt.modules.macdefaults._run_defaults_cmd", mock):
@ -132,9 +132,9 @@ def test_write_float():
)
def test_write_array():
def test_write_default_array():
"""
Test writing an array setting
Test writing a default array setting
"""
mock = MagicMock(return_value={"retcode": 0})
with patch("salt.modules.macdefaults._run_defaults_cmd", mock):
@ -147,9 +147,9 @@ def test_write_array():
)
def test_write_dictionary():
def test_write_default_dictionary():
"""
Test writing a dictionary setting
Test writing a default dictionary setting
"""
mock = MagicMock(return_value={"retcode": 0})
with patch("salt.modules.macdefaults._run_defaults_cmd", mock):
@ -186,7 +186,7 @@ def test_read_default():
assert result == "Server"
def test_read_with_user():
def test_read_default_with_user():
"""
Test reading a default setting as a specific user
"""
@ -210,9 +210,9 @@ def test_read_with_user():
assert result == "Server"
def test_read_integer():
def test_read_default_integer():
"""
Test reading an integer setting
Test reading a default integer setting
"""
def custom_run_defaults_cmd(action, runas=None):
@ -234,9 +234,9 @@ def test_read_integer():
assert result == 12
def test_read_float():
def test_read_default_float():
"""
Test reading a float setting
Test reading a default float setting
"""
def custom_run_defaults_cmd(action, runas=None):
@ -258,15 +258,15 @@ def test_read_float():
assert result == 0.85
def test_read_array():
def test_read_default_array():
"""
Test reading an array setting
Test reading a default array setting
"""
defaults_output = """(
element 1,
element 2,
0.1,
0.1000,
1
)"""
@ -289,14 +289,16 @@ def test_read_array():
assert result == ["element 1", "element 2", 0.1, 1]
def test_read_dictionary():
def test_read_default_dictionary():
"""
Test reading a dictionary setting
Test reading a default dictionary setting
"""
defaults_output = """{
keyCode = 36;
modifierFlags = 786432;
anotherKey = "another value with spaces";
floatNumber = 0.8500;
}"""
def custom_run_defaults_cmd(action, runas=None):
@ -315,7 +317,12 @@ def test_read_dictionary():
call('read-type "com.apple.CrashReporter" "Crash"', runas=None),
]
)
assert result == {"keyCode": 36, "modifierFlags": 786432}
assert result == {
"keyCode": 36,
"modifierFlags": 786432,
"anotherKey": "another value with spaces",
"floatNumber": 0.85,
}
def test_delete_default():
@ -331,9 +338,9 @@ def test_delete_default():
)
def test_delete_with_user():
def test_delete_default_with_user():
"""
Test delete a setting as a specific user
Test delete a default setting as a specific user
"""
mock = MagicMock(return_value={"retcode": 0})
with patch("salt.modules.macdefaults._run_defaults_cmd", mock):

View file

@ -9,7 +9,7 @@ def configure_loader_modules():
return {macdefaults: {}}
def test_write():
def test_write_default():
"""
Test writing a default setting
"""
@ -34,7 +34,7 @@ def test_write():
assert out == expected
def test_write_set():
def test_write_default_already_set():
"""
Test writing a default setting that is already set
"""
@ -57,7 +57,7 @@ def test_write_set():
assert out == expected
def test_write_boolean():
def test_write_default_boolean():
"""
Test writing a default setting with a boolean
"""
@ -68,7 +68,7 @@ def test_write_boolean():
"result": True,
}
read_mock = MagicMock(return_value="0")
read_mock = MagicMock(return_value=False)
write_mock = MagicMock(return_value={"retcode": 0})
with patch.dict(
macdefaults.__salt__,
@ -82,9 +82,9 @@ def test_write_boolean():
assert out == expected
def test_write_boolean_match():
def test_write_default_boolean_already_set():
"""
Test writing a default setting with a boolean that is already set to the same value
Test writing a default setting with a boolean that is already set
"""
expected = {
"changes": {},
@ -105,7 +105,7 @@ def test_write_boolean_match():
assert out == expected
def test_write_integer():
def test_write_default_integer():
"""
Test writing a default setting with a integer
"""
@ -116,7 +116,7 @@ def test_write_integer():
"result": True,
}
read_mock = MagicMock(return_value="99")
read_mock = MagicMock(return_value=99)
write_mock = MagicMock(return_value={"retcode": 0})
with patch.dict(
macdefaults.__salt__,
@ -130,9 +130,9 @@ def test_write_integer():
assert out == expected
def test_write_integer_match():
def test_write_default_integer_already_set():
"""
Test writing a default setting with a integer that is already set to the same value
Test writing a default setting with an integer that is already set
"""
expected = {
"changes": {},
@ -153,7 +153,298 @@ def test_write_integer_match():
assert out == expected
def test_absent_already():
def test_write_default_float():
"""
Test writing a default setting with a float
"""
expected = {
"changes": {"written": "com.apple.something Key is set to 0.865"},
"comment": "",
"name": "Key",
"result": True,
}
read_mock = MagicMock(return_value=0.4)
write_mock = MagicMock(return_value={"retcode": 0})
with patch.dict(
macdefaults.__salt__,
{"macdefaults.read": read_mock, "macdefaults.write": write_mock},
):
out = macdefaults.write("Key", "com.apple.something", 0.865, 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
)
assert out == expected
def test_write_default_float_already_set():
"""
Test writing a default setting with a float that is already set_default
"""
expected = {
"changes": {},
"comment": "com.apple.something Key is already set to 0.865",
"name": "Key",
"result": True,
}
read_mock = MagicMock(return_value=0.865)
write_mock = MagicMock(return_value={"retcode": 0})
with patch.dict(
macdefaults.__salt__,
{"macdefaults.read": read_mock, "macdefaults.write": write_mock},
):
out = macdefaults.write("Key", "com.apple.something", 0.86500, vtype="float")
read_mock.assert_called_once_with("com.apple.something", "Key", None)
assert not write_mock.called
assert out == expected
def test_write_default_array():
"""
Test writing a default setting with an array
"""
value = ["a", 1, 0.5, True]
expected = {
"changes": {"written": f"com.apple.something Key is set to {value}"},
"comment": "",
"name": "Key",
"result": True,
}
read_mock = MagicMock(return_value=None)
write_mock = MagicMock(return_value={"retcode": 0})
with patch.dict(
macdefaults.__salt__,
{"macdefaults.read": read_mock, "macdefaults.write": write_mock},
):
out = macdefaults.write("Key", "com.apple.something", value, vtype="array")
read_mock.assert_called_once_with("com.apple.something", "Key", None)
write_mock.assert_called_once_with(
"com.apple.something", "Key", value, "array", None
)
assert out == expected
def test_write_default_array_already_set():
"""
Test writing a default setting with an array that is already set
"""
value = ["a", 1, 0.5, True]
expected = {
"changes": {},
"comment": f"com.apple.something Key is already set to {value}",
"name": "Key",
"result": True,
}
read_mock = MagicMock(return_value=value)
write_mock = MagicMock(return_value={"retcode": 0})
with patch.dict(
macdefaults.__salt__,
{"macdefaults.read": read_mock, "macdefaults.write": write_mock},
):
out = macdefaults.write("Key", "com.apple.something", value, vtype="array")
read_mock.assert_called_once_with("com.apple.something", "Key", None)
assert not write_mock.called
assert out == expected
def test_write_default_array_add():
"""
Test writing a default setting adding an array to another
"""
write_value = ["a", 1]
read_value = ["b", 2]
expected = {
"changes": {"written": f"com.apple.something Key is set to {write_value}"},
"comment": "",
"name": "Key",
"result": True,
}
read_mock = MagicMock(return_value=read_value)
write_mock = MagicMock(return_value={"retcode": 0})
with patch.dict(
macdefaults.__salt__,
{"macdefaults.read": read_mock, "macdefaults.write": write_mock},
):
out = macdefaults.write(
"Key", "com.apple.something", write_value, vtype="array-add"
)
read_mock.assert_called_once_with("com.apple.something", "Key", None)
write_mock.assert_called_once_with(
"com.apple.something", "Key", write_value, "array-add", None
)
assert out == expected
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
"""
write_value = ["a", 1]
read_value = ["b", 1, "a", 2]
expected = {
"changes": {"written": f"com.apple.something Key is set to {write_value}"},
"comment": "",
"name": "Key",
"result": True,
}
read_mock = MagicMock(return_value=read_value)
write_mock = MagicMock(return_value={"retcode": 0})
with patch.dict(
macdefaults.__salt__,
{"macdefaults.read": read_mock, "macdefaults.write": write_mock},
):
out = macdefaults.write(
"Key", "com.apple.something", write_value, vtype="array-add"
)
read_mock.assert_called_once_with("com.apple.something", "Key", None)
write_mock.assert_called_once_with(
"com.apple.something", "Key", write_value, "array-add", None
)
assert out == expected
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]
read_value = ["b", "a", 1, 2]
expected = {
"changes": {},
"comment": f"com.apple.something Key is already set to {write_value}",
"name": "Key",
"result": True,
}
read_mock = MagicMock(return_value=read_value)
write_mock = MagicMock(return_value={"retcode": 0})
with patch.dict(
macdefaults.__salt__,
{"macdefaults.read": read_mock, "macdefaults.write": write_mock},
):
out = macdefaults.write(
"Key", "com.apple.something", write_value, vtype="array-add"
)
read_mock.assert_called_once_with("com.apple.something", "Key", None)
assert not write_mock.called
assert out == expected
def test_write_default_dict():
"""
Test writing a default setting with a dictionary
"""
value = {"string": "bar", "integer": 1, "float": 0.5, "boolean": True}
expected = {
"changes": {"written": f"com.apple.something Key is set to {value}"},
"comment": "",
"name": "Key",
"result": True,
}
read_mock = MagicMock(return_value=None)
write_mock = MagicMock(return_value={"retcode": 0})
with patch.dict(
macdefaults.__salt__,
{"macdefaults.read": read_mock, "macdefaults.write": write_mock},
):
out = macdefaults.write("Key", "com.apple.something", value, vtype="dict")
read_mock.assert_called_once_with("com.apple.something", "Key", None)
write_mock.assert_called_once_with(
"com.apple.something", "Key", value, "dict", None
)
assert out == expected
def test_write_default_dict_already_set():
"""
Test writing a default setting with a dictionary that is already set
"""
value = {"string": "bar", "integer": 1, "float": 0.5, "boolean": True}
expected = {
"changes": {},
"comment": f"com.apple.something Key is already set to {value}",
"name": "Key",
"result": True,
}
read_mock = MagicMock(return_value=value)
write_mock = MagicMock(return_value={"retcode": 0})
with patch.dict(
macdefaults.__salt__,
{"macdefaults.read": read_mock, "macdefaults.write": write_mock},
):
out = macdefaults.write("Key", "com.apple.something", value, vtype="dict")
read_mock.assert_called_once_with("com.apple.something", "Key", None)
assert not write_mock.called
assert out == expected
def test_write_default_dict_add():
"""
Test writing a default setting adding elements to a dictionary
"""
write_value = {"string": "bar", "integer": 1}
read_value = {"integer": 1, "float": 0.5, "boolean": True}
expected = {
"changes": {"written": f"com.apple.something Key is set to {write_value}"},
"comment": "",
"name": "Key",
"result": True,
}
read_mock = MagicMock(return_value=read_value)
write_mock = MagicMock(return_value={"retcode": 0})
with patch.dict(
macdefaults.__salt__,
{"macdefaults.read": read_mock, "macdefaults.write": write_mock},
):
out = macdefaults.write(
"Key", "com.apple.something", write_value, vtype="dict-add"
)
read_mock.assert_called_once_with("com.apple.something", "Key", None)
write_mock.assert_called_once_with(
"com.apple.something", "Key", write_value, "dict-add", None
)
assert out == expected
def test_write_default_dict_add_already_set():
"""
Test writing a default setting adding elements to a dictionary that is already set
"""
write_value = {"string": "bar", "integer": 1}
read_value = {"string": "bar", "integer": 1, "float": 0.5, "boolean": True}
expected = {
"changes": {},
"comment": f"com.apple.something Key is already set to {write_value}",
"name": "Key",
"result": True,
}
read_mock = MagicMock(return_value=read_value)
write_mock = MagicMock(return_value={"retcode": 0})
with patch.dict(
macdefaults.__salt__,
{"macdefaults.read": read_mock, "macdefaults.write": write_mock},
):
out = macdefaults.write(
"Key", "com.apple.something", write_value, vtype="dict-add"
)
read_mock.assert_called_once_with("com.apple.something", "Key", None)
assert not write_mock.called
assert out == expected
def test_absent_default_already():
"""
Test ensuring non-existent defaults value is absent
"""
@ -171,9 +462,9 @@ def test_absent_already():
assert out == expected
def test_absent_deleting_existing():
def test_absent_default_deleting_existing():
"""
Test removing an existing value
Test removing an existing default value
"""
expected = {
"changes": {"absent": "com.apple.something Key is now absent"},