Merge pull request 'api: check deletions if possible' (#5538) from fix-deletions into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5538
This commit is contained in:
hsjobeki
2025-10-16 10:50:15 +00:00
9 changed files with 218 additions and 289 deletions

View File

@@ -9,6 +9,8 @@ let
}; };
} }
{ {
# Mutable data
_file = "deferred.json";
foo = { foo = {
a = { }; a = { };
b = { }; b = { };
@@ -16,7 +18,9 @@ let
} }
# Merge the "inventory.json" # Merge the "inventory.json"
(builtins.fromJSON (builtins.readFile ./deferred.json)) (lib.modules.setDefaultModuleLocation "deferred.json" (
builtins.fromJSON (builtins.readFile ./deferred.json)
))
]; ];
}; };
in in

View File

@@ -218,6 +218,7 @@ class InventoryStore:
current_priority, current_priority,
dict(data_eval), dict(data_eval),
dict(data_disk), dict(data_disk),
inventory_file_name=self.inventory_file.name,
) )
return WriteInfo(write_map, data_eval, data_disk) return WriteInfo(write_map, data_eval, data_disk)

View File

@@ -11,6 +11,7 @@ from clan_lib.errors import ClanError
from clan_lib.nix import nix_eval from clan_lib.nix import nix_eval
from clan_lib.persist.inventory_store import InventoryStore from clan_lib.persist.inventory_store import InventoryStore
from clan_lib.persist.path_utils import delete_by_path, set_value_by_path from clan_lib.persist.path_utils import delete_by_path, set_value_by_path
from clan_lib.persist.write_rules import PersistenceAttribute
class MockFlake: class MockFlake:
@@ -120,16 +121,18 @@ def test_simple_read_write(setup_test_files: Path) -> None:
invalid_data = {"protected": "foo"} invalid_data = {"protected": "foo"}
with pytest.raises(ClanError) as e: with pytest.raises(ClanError) as e:
store.write(invalid_data, "test", commit=False) # type: ignore[arg-type] store.write(invalid_data, "test", commit=False) # type: ignore[arg-type]
assert "Path 'protected' is readonly" in str(e.value) assert "Cannot delete path 'foo'" in str(e.value)
# Test the data is not touched # Test the data is not touched
assert store.read() == data assert store.read() == data
assert store._get_persisted() == {"foo": "foo"} assert store._get_persisted() == {"foo": "foo"}
# Remove the foo key from the persisted data # Remove the foo key from the persisted data
# Technically data = { } should also work # Cannot remove keys at the root level (root is always a total submodule)
data = {"protected": "protected"} # type: ignore[typeddict-unknown-key] data = {"protected": "protected"} # type: ignore[typeddict-unknown-key]
store.write(data, "test", commit=False) # type: ignore[arg-type] with pytest.raises(ClanError) as e:
store.write(data, "test", commit=False) # type: ignore[arg-type]
assert "Cannot delete path 'foo'" in str(e.value)
@pytest.mark.with_core @pytest.mark.with_core
@@ -150,6 +153,13 @@ def test_simple_deferred(setup_test_files: Path) -> None:
_keys={"foo"}, # disable toplevel filtering _keys={"foo"}, # disable toplevel filtering
) )
attribute_props = store._write_map().writeables
assert attribute_props == {
("foo",): {PersistenceAttribute.WRITE},
("foo", "a"): {PersistenceAttribute.WRITE, PersistenceAttribute.DELETE},
("foo", "b"): {PersistenceAttribute.WRITE, PersistenceAttribute.DELETE},
}
data = store.read() data = store.read()
assert data == {"foo": {"a": {}, "b": {}}} assert data == {"foo": {"a": {}, "b": {}}}

View File

@@ -6,6 +6,7 @@ from clan_lib.persist.path_utils import (
PathTuple, PathTuple,
flatten_data_structured, flatten_data_structured,
list_difference, list_difference,
path_to_string,
should_skip_path, should_skip_path,
) )
from clan_lib.persist.validate import ( from clan_lib.persist.validate import (
@@ -15,7 +16,7 @@ from clan_lib.persist.validate import (
validate_type_compatibility, validate_type_compatibility,
validate_writeability, validate_writeability,
) )
from clan_lib.persist.write_rules import AttributeMap from clan_lib.persist.write_rules import AttributeMap, PersistenceAttribute
def find_deleted_paths_structured( def find_deleted_paths_structured(
@@ -53,37 +54,13 @@ def find_deleted_paths_structured(
# deleted_paths.add(current_path) # deleted_paths.add(current_path)
# Persisted was a dict, update is not a dict # Persisted was a dict, update is not a dict
# This can happen if user sets the value to None, explicitly. # produce 'set path None' is not a deletion # This can happen if user sets the value to None, explicitly.
# produce 'set path None' is not a deletion
pass pass
return deleted_paths return deleted_paths
def calculate_static_data(
all_values: dict[str, Any], persisted: dict[str, Any]
) -> dict[PathTuple, Any]:
"""Calculate the static (read-only) data by finding what exists in all_values
but not in persisted data.
This gives us a clear view of what cannot be modified/deleted.
"""
all_flat = flatten_data_structured(all_values)
persisted_flat = flatten_data_structured(persisted)
static_flat = {}
for key, value in all_flat.items():
if key not in persisted_flat:
# This key exists only in static data
static_flat[key] = value
elif isinstance(value, list) and isinstance(persisted_flat[key], list):
# For lists, find items that are only in all_values (static items)
static_items = list_difference(value, persisted_flat[key])
if static_items:
static_flat[key] = static_items
return static_flat
def calc_patches( def calc_patches(
persisted: dict[str, Any], persisted: dict[str, Any],
update: dict[str, Any], update: dict[str, Any],
@@ -109,27 +86,27 @@ def calc_patches(
ClanError: When validation fails or invalid operations are attempted ClanError: When validation fails or invalid operations are attempted
""" """
# Calculate static data using structured paths
static_data = calculate_static_data(all_values, persisted)
# Flatten all data structures using structured paths # Flatten all data structures using structured paths
# persisted_flat = flatten_data_structured(persisted) persisted_flat = flatten_data_structured(persisted)
update_flat = flatten_data_structured(update) update_flat = flatten_data_structured(update)
all_values_flat = flatten_data_structured(all_values) all_values_flat = flatten_data_structured(all_values)
# Early validation: ensure we're not trying to modify static-only paths
# validate_no_static_modification(update_flat, static_data)
# Find paths marked for deletion # Find paths marked for deletion
delete_paths = find_deleted_paths_structured(all_values, update) delete_paths = find_deleted_paths_structured(all_values, update)
# Validate deletions don't affect static data # Validate deletions. Ensure deletions are allowed
# TODO: We currently cannot validate this properly. # Prior introspection adds PersistenceAttribute.DELETE to paths that can be deleted
# for delete_path in delete_paths: for delete_path in delete_paths:
# for static_path in static_data: if delete_path not in attribute_props:
# if path_starts_with(static_path, delete_path): msg = f"""Cannot delete path '{path_to_string(delete_path)}' - '{path_to_string(delete_path)}' is not a valid path.
# msg = f"Cannot delete path '{path_to_string(delete_path)}' - Readonly path '{path_to_string(static_path)}' is set via .nix file" This is an internal error that should not happen.
# raise ClanError(msg) Please report this issue at https://git.clan.lol/clan/clan-core/issues
"""
raise ClanError(msg)
if PersistenceAttribute.DELETE not in attribute_props.get(delete_path, {}):
msg = f"Cannot delete path '{path_to_string(delete_path)}' - '{path_to_string(delete_path)}' is set via .nix file"
raise ClanError(msg)
# Get all paths that might need processing # Get all paths that might need processing
all_paths: set[PathTuple] = set(all_values_flat) | set(update_flat) all_paths: set[PathTuple] = set(all_values_flat) | set(update_flat)
@@ -154,7 +131,6 @@ def calc_patches(
continue continue
# Validate the change is allowed # Validate the change is allowed
validate_no_static_deletion(path, new_value, static_data)
validate_writeability(path, attribute_props) validate_writeability(path, attribute_props)
validate_type_compatibility(path, old_value, new_value) validate_type_compatibility(path, old_value, new_value)
validate_list_uniqueness(path, new_value) validate_list_uniqueness(path, new_value)
@@ -162,7 +138,11 @@ def calc_patches(
patch_value = new_value # Init patch_value = new_value # Init
if isinstance(new_value, list): if isinstance(new_value, list):
patch_value = list_difference(new_value, static_data.get(path, [])) static_items = list_difference(
old_value or [], persisted_flat.get(path, [])
)
validate_no_static_deletion(path, new_value, static_items)
patch_value = list_difference(new_value, static_items)
patches[path] = patch_value patches[path] = patch_value

View File

@@ -6,7 +6,6 @@ import pytest
from clan_lib.errors import ClanError from clan_lib.errors import ClanError
from clan_lib.persist.patch_engine import ( from clan_lib.persist.patch_engine import (
calc_patches, calc_patches,
calculate_static_data,
merge_objects, merge_objects,
) )
from clan_lib.persist.path_utils import ( from clan_lib.persist.path_utils import (
@@ -19,195 +18,6 @@ from clan_lib.persist.write_rules import (
compute_attribute_persistence, compute_attribute_persistence,
) )
# --- calculate_static_data ---
def test_calculate_static_data_basic() -> None:
all_values = {
"name": "example",
"version": 1,
"settings": {
"optionA": True,
"optionB": False,
"listSetting": [1, 2, 3, 4],
},
"staticOnly": "staticValue",
}
persisted = {
"name": "example",
"version": 1,
"settings": {
"optionA": True,
"listSetting": [2, 3],
},
}
expected_static = {
("settings", "optionB"): False,
("settings", "listSetting"): [1, 4],
("staticOnly",): "staticValue",
}
static_data = calculate_static_data(all_values, persisted)
assert static_data == expected_static
def test_calculate_static_data_no_static() -> None:
all_values = {
"name": "example",
"version": 1,
"settings": {
"optionA": True,
"listSetting": [1, 2, 3],
},
}
persisted = {
"name": "example",
"version": 1,
"settings": {
"optionA": True,
"listSetting": [1, 2, 3],
},
}
expected_static: dict = {}
static_data = calculate_static_data(all_values, persisted)
assert static_data == expected_static
# See: https://git.clan.lol/clan/clan-core/issues/5231
# Uncomment this test if the issue is resolved
# def test_calculate_static_data_no_static_sets() -> None:
# all_values = {
# "instance": {
# "hello": {
# "roles": {
# "default": {
# "machines": {
# "jon": {
# # Default
# "settings": {
# }
# }
# }
# }
# }
# }
# }
# }
# persisted = {
# "instance": {
# "hello": {
# "roles": {
# "default": {
# "machines": {
# "jon": {
# }
# }
# }
# }
# }
# }
# }
# expected_static: dict = {}
# static_data = calculate_static_data(all_values, persisted)
# assert static_data == expected_static
def test_calculate_static_data_all_static() -> None:
all_values = {
"name": "example",
"version": 1,
"settings": {
"optionA": True,
"listSetting": [1, 2, 3],
},
"staticOnly": "staticValue",
}
persisted: dict = {}
expected_static = {
("name",): "example",
("version",): 1,
("settings", "optionA"): True,
("settings", "listSetting"): [1, 2, 3],
("staticOnly",): "staticValue",
}
static_data = calculate_static_data(all_values, persisted)
assert static_data == expected_static
def test_calculate_static_data_empty_all_values() -> None:
# This should never happen in practice, but we test it for completeness.
# Maybe this should emit a warning in the future?
all_values: dict = {}
persisted = {
"name": "example",
"version": 1,
}
expected_static: dict = {}
static_data = calculate_static_data(all_values, persisted)
assert static_data == expected_static
def test_calculate_nested_dicts() -> None:
all_values = {
"level1": {
"level2": {
"staticKey": "staticValue",
"persistedKey": "persistedValue",
},
"anotherStatic": 42,
},
"topLevelStatic": True,
}
persisted = {
"level1": {
"level2": {
"persistedKey": "persistedValue",
},
},
}
expected_static = {
("level1", "level2", "staticKey"): "staticValue",
("level1", "anotherStatic"): 42,
("topLevelStatic",): True,
}
static_data = calculate_static_data(all_values, persisted)
assert static_data == expected_static
def test_dot_in_keys() -> None:
all_values = {
"key.foo": "staticValue",
"key": {
"foo": "anotherStaticValue",
},
}
persisted: dict = {}
expected_static = {
("key.foo",): "staticValue",
("key", "foo"): "anotherStaticValue",
}
static_data = calculate_static_data(all_values, persisted)
assert static_data == expected_static
# --------- calc_patches ---------
def test_update_simple() -> None: def test_update_simple() -> None:
prios = { prios = {
@@ -379,34 +189,79 @@ def test_update_parent_non_writeable() -> None:
assert "Path 'foo.bar' is readonly." in str(error.value) assert "Path 'foo.bar' is readonly." in str(error.value)
# TODO: Resolve the issue https://git.clan.lol/clan/clan-core/issues/5231 def test_calculate_static_data_no_static_sets() -> None:
# def test_remove_non_writable_attrs() -> None: all_values: dict = {
# prios = { "instance": {
# "foo": { "hello": {
# "__this": {"total": True, "prio": 100}, "static": {},
# # We cannot delete children because "foo" is total }
# }, }
# } }
persisted: dict = {"instance": {"hello": {}}}
attribute_props = {
("instance",): {PersistenceAttribute.WRITE},
("instance", "hello"): {
PersistenceAttribute.WRITE,
PersistenceAttribute.DELETE,
},
("instance", "hello", "static"): {PersistenceAttribute.WRITE},
}
# data_eval: dict = {"foo": {"bar": {}, "baz": {}}} update: dict = {
"instance": {
# static is a key that is defined in nix, and cannot be deleted
# It needs to be present.
"hello": {} # Error: "static" cannot be deleted
}
}
# data_disk: dict = {} with pytest.raises(ClanError) as error:
calc_patches(
persisted=persisted,
all_values=all_values,
update=update,
attribute_props=attribute_props,
)
# attribute_props = compute_attribute_persistence(prios, data_eval, data_disk) assert "Cannot delete path 'instance.hello.static'" in str(error.value)
# update: dict = {
# "foo": {
# "bar": {}, # <- user leaves this value
# # User removed "baz"
# },
# }
# with pytest.raises(ClanError) as error: # Same as above, but using legacy priorities
# calc_patches( # This allows deletion, but is slightly wrong.
# data_disk, update, all_values=data_eval, attribute_props=attribute_props # The correct behavior requires nixos >= 25.11
# ) def test_calculate_static_data_no_static_sets_legacy() -> None:
prios = {
"instance": {
"__prio": 100, # <- writeable: "foo"
"hello": {
"__prio": 100,
"static": {"__prio": 100},
},
},
}
# assert "Cannot delete path 'foo.baz'" in str(error.value) all_values: dict = {
"instance": {
"hello": {
"static": {},
}
}
}
persisted: dict = {"instance": {"hello": {}}}
attribute_props = compute_attribute_persistence(prios, all_values, persisted)
update: dict = {
"instance": {
"hello": {}, # <- user removes "static"
},
}
updates, deletes = calc_patches(
persisted, update, all_values=all_values, attribute_props=attribute_props
)
assert updates == {("instance", "hello"): {}}
assert deletes == {("instance", "hello", "static")}
def test_update_list() -> None: def test_update_list() -> None:
@@ -456,6 +311,37 @@ def test_update_list() -> None:
assert patchset == {("foo",): []} assert patchset == {("foo",): []}
def test_list_delete_static() -> None:
prios = {
"foo": {
"__prio": 100, # <- writeable: "foo"
},
}
data_eval = {
# [ "A" ] is defined in nix.
"foo": ["A", "B"],
}
data_disk = {"foo": ["B"]}
attribute_props = compute_attribute_persistence(prios, data_eval, data_disk)
assert attribute_props == {
("foo",): {PersistenceAttribute.WRITE},
}
# Try to remove "A" from the list
update = {"foo": ["B"]}
with pytest.raises(ClanError) as error:
calc_patches(
data_disk, update, all_values=data_eval, attribute_props=attribute_props
)
assert "Path 'foo' doesn't contain static items ['A']" in str(error.value)
def test_update_list_duplicates() -> None: def test_update_list_duplicates() -> None:
prios = { prios = {
"foo": { "foo": {
@@ -526,27 +412,29 @@ def test_dont_persist_defaults() -> None:
def test_set_null() -> None: def test_set_null() -> None:
data_eval: dict = { data_eval: dict = {
"foo": {}, "root": {
"bar": {}, "foo": {},
"bar": {},
}
} }
data_disk = data_eval data_disk = data_eval
# User set Foo to null # User set Foo to null
# User deleted bar # User deleted bar
update = {"foo": None} update = {"root": {"foo": None}}
patchset, delete_set = calc_patches( patchset, delete_set = calc_patches(
data_disk, data_disk,
update, update,
all_values=data_eval, all_values=data_eval,
attribute_props=compute_attribute_persistence( attribute_props=compute_attribute_persistence(
{"__prio": 100, "foo": {"__prio": 100}, "bar": {"__prio": 100}}, {"root": {"__prio": 100, "foo": {"__prio": 100}, "bar": {"__prio": 100}}},
data_eval, data_eval,
data_disk, data_disk,
), ),
) )
assert patchset == {("foo",): None} assert patchset == {("root", "foo"): None}
assert delete_set == {("bar",)} assert delete_set == {("root", "bar")}
def test_machine_delete() -> None: def test_machine_delete() -> None:
@@ -760,7 +648,7 @@ def test_delete_key_non_writeable() -> None:
data_disk, update, all_values=data_eval, attribute_props=attribute_props data_disk, update, all_values=data_eval, attribute_props=attribute_props
) )
assert "Path 'foo' is readonly." in str(error.value) assert "Cannot delete path 'foo.bar'" in str(error.value)
# --- test merge_objects --- # --- test merge_objects ---

View File

@@ -11,23 +11,14 @@ from clan_lib.persist.write_rules import AttributeMap, is_writeable_path
def validate_no_static_deletion( def validate_no_static_deletion(
path: PathTuple, new_value: Any, static_data: dict[PathTuple, Any] path: PathTuple, new_list: list[Any], static_items: list[Any]
) -> None: ) -> None:
"""Validate that we're not trying to delete static data.""" """Validate that we're not trying to delete static items from a list."""
# Check if we're trying to delete a path that exists in static data missing_static = [item for item in static_items if item not in new_list]
if path in static_data and new_value is None: if missing_static:
msg = f"Path '{path_to_string(path)}' is readonly - since its defined via a .nix file" msg = f"Path '{path_to_string(path)}' doesn't contain static items {missing_static} - They are readonly - since they are defined via a .nix file"
raise ClanError(msg) raise ClanError(msg)
# For lists, check if we're trying to remove static items
if isinstance(new_value, list) and path in static_data:
static_items = static_data[path]
if isinstance(static_items, list):
missing_static = [item for item in static_items if item not in new_value]
if missing_static:
msg = f"Path '{path_to_string(path)}' doesn't contain static items {missing_static} - They are readonly - since they are defined via a .nix file"
raise ClanError(msg)
def validate_writeability(path: PathTuple, writeables: AttributeMap) -> None: def validate_writeability(path: PathTuple, writeables: AttributeMap) -> None:
"""Validate that a path is writeable.""" """Validate that a path is writeable."""

View File

@@ -101,16 +101,16 @@ def is_key_writeable(
return is_mergeable_type(value_in_all) or exists_in_persisted return is_mergeable_type(value_in_all) or exists_in_persisted
def get_inventory_exclusive(value: dict) -> bool | None: def get_inventory_exclusive(value: dict, inventory_file_name: str) -> bool | None:
if "__this" not in value: if "__this" not in value:
return None return None
definition_locations = value.get("__this", {}).get("files") definition_locations: list[str] = value.get("__this", {}).get("files")
if not definition_locations: if not definition_locations:
return None return None
return ( return len(definition_locations) == 1 and definition_locations[0].endswith(
len(definition_locations) == 1 and definition_locations[0] == "inventory.json" inventory_file_name
) )
@@ -129,6 +129,8 @@ def _determine_props_recursive(
parent_redonly: bool = False, parent_redonly: bool = False,
results: AttributeMap | None = None, results: AttributeMap | None = None,
parent_total: bool = True, parent_total: bool = True,
*,
inventory_file_name: str,
) -> AttributeMap: ) -> AttributeMap:
"""Recursively determine writeability for all paths in the priority structure. """Recursively determine writeability for all paths in the priority structure.
@@ -158,7 +160,7 @@ def _determine_props_recursive(
# Unless there is a default that applies instead, when removed. Currently we cannot test that. # Unless there is a default that applies instead, when removed. Currently we cannot test that.
# So we assume exclusive values can be removed. In reality we might need to check defaults too. (TODO) # So we assume exclusive values can be removed. In reality we might need to check defaults too. (TODO)
# Total parents prevent deletion of immediate children. # Total parents prevent deletion of immediate children.
is_inventory_exclusive = get_inventory_exclusive(value) is_inventory_exclusive = get_inventory_exclusive(value, inventory_file_name)
if not parent_total and ( if not parent_total and (
is_inventory_exclusive or is_inventory_exclusive is None is_inventory_exclusive or is_inventory_exclusive is None
): ):
@@ -187,7 +189,8 @@ def _determine_props_recursive(
effective_priority, effective_priority,
parent_redonly=True, parent_redonly=True,
results=results, results=results,
parent_total=value.get("__this", {}).get("total", False), parent_total=get_totality(value),
inventory_file_name=inventory_file_name,
) )
else: else:
# Determine writeability based on rules # Determine writeability based on rules
@@ -218,13 +221,18 @@ def _determine_props_recursive(
parent_redonly=False, parent_redonly=False,
results=results, results=results,
parent_total=get_totality(value), parent_total=get_totality(value),
inventory_file_name=inventory_file_name,
) )
return results return results
def compute_attribute_persistence( def compute_attribute_persistence(
priorities: dict[str, Any], all_values: dict[str, Any], persisted: dict[str, Any] priorities: dict[str, Any],
all_values: dict[str, Any],
persisted: dict[str, Any],
*,
inventory_file_name: str = "inventory.json",
) -> AttributeMap: ) -> AttributeMap:
"""Determine writeability for all paths based on priorities and current data. """Determine writeability for all paths based on priorities and current data.
@@ -236,6 +244,7 @@ def compute_attribute_persistence(
priorities: The priority structure defining writeability rules. See: 'clanInternals.inventoryClass.introspection' priorities: The priority structure defining writeability rules. See: 'clanInternals.inventoryClass.introspection'
all_values: All values in the inventory, See: 'clanInternals.inventoryClass.allValues' all_values: All values in the inventory, See: 'clanInternals.inventoryClass.allValues'
persisted: The current mutable state of the inventory, see: 'readFile inventory.json' persisted: The current mutable state of the inventory, see: 'readFile inventory.json'
inventory_file_name: The name of the inventory file, defaults to "inventory.json"
Returns: Returns:
Dict with sets of writeable and non-writeable paths using tuple keys Dict with sets of writeable and non-writeable paths using tuple keys
@@ -251,4 +260,6 @@ def compute_attribute_persistence(
) )
raise ClanError(msg) raise ClanError(msg)
return _determine_props_recursive(priorities, all_values, persisted) return _determine_props_recursive(
priorities, all_values, persisted, inventory_file_name=inventory_file_name
)

View File

@@ -252,3 +252,45 @@ def test_attributes_totality() -> None:
("foo",): {PersistenceAttribute.WRITE}, ("foo",): {PersistenceAttribute.WRITE},
("foo", "a"): {PersistenceAttribute.WRITE}, ("foo", "a"): {PersistenceAttribute.WRITE},
} }
def test_totality_simple() -> None:
introspection = {
"foo": { # Foo is non-total all keys are potententially deleteable
"__this": {
"files": ["<unknown-file>"],
"headType": "attrsOf",
"nullable": False,
"prio": 100,
"total": False,
},
"a": { # Can delete "a" because it is defined somewhere else
"__this": {
"files": ["inventory.json", "<unknown-file>"],
"headType": "deferredModule",
"nullable": False,
"prio": 100,
"total": False,
}
},
"b": { # Can remove "b" because it is only defined in inventory.json
"__this": {
"files": ["inventory.json"],
"headType": "deferredModule",
"nullable": False,
"prio": 100,
"total": False,
}
},
}
}
data_eval: dict = {"foo": {"a": {}, "b": {}}}
persisted: dict = {"foo": {"a": {}, "b": {}}}
res = compute_attribute_persistence(introspection, data_eval, persisted)
assert res == {
("foo",): {PersistenceAttribute.WRITE},
("foo", "a"): {PersistenceAttribute.WRITE},
("foo", "b"): {PersistenceAttribute.WRITE, PersistenceAttribute.DELETE},
}

View File

@@ -203,7 +203,9 @@ def test_update_service_instance(
{ {
"morning": { "morning": {
"machines": { "machines": {
"jon": {}, # Unset the machine settings "jon": {
"settings": {} # type: ignore[typeddict-item]
},
"sara": { "sara": {
"settings": { # type: ignore[typeddict-item] "settings": { # type: ignore[typeddict-item]
"greeting": "sara", "greeting": "sara",