Skip to content

Commit 5424968

Browse files
Use simplified upsert from yamltrip 0.6.0
1 parent 794ed57 commit 5424968

4 files changed

Lines changed: 86 additions & 54 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ dependencies = [
6161
"tomlkit>=0.13.3",
6262
"typer>=0.12.4",
6363
"typing-extensions>=3.10.0.0",
64-
"yamltrip>=0.5.0",
64+
"yamltrip>=0.6.0",
6565
]
6666
scripts.usethis = "usethis.__main__:app"
6767

src/usethis/_file/yaml/io_.py

Lines changed: 24 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -301,49 +301,30 @@ def _upsert_safe(
301301
*,
302302
exists_ok: bool = False,
303303
) -> yamltrip.Document:
304-
"""Upsert a value into a yamltrip Document, handling empty documents."""
305-
# For complex values (list/dict) where the key doesn't exist yet, add a
306-
# null placeholder first then replace — this forces block-style output.
307-
if isinstance(value, (list, dict)) and tuple(keys) not in doc:
308-
try:
309-
doc = doc.upsert(*keys, value=None)
310-
except yamltrip.PatchError:
311-
pass
312-
else:
313-
return doc.upsert(*keys, value=value)
304+
"""Upsert a value into a yamltrip Document.
314305
306+
A RoutingError means a key in the path passes through a non-mapping node. When
307+
overwriting is allowed we prune the conflicting prefix and retry; otherwise we
308+
translate it into a friendly YAMLValueAlreadySetError.
309+
"""
315310
try:
311+
# Seed the leaf as null first, then write the value. Seeding forces
312+
# complex values to render in block style (rather than flow style) and
313+
# fully replaces any existing value at the path.
314+
doc = doc.upsert(*keys, value=None)
316315
return doc.upsert(*keys, value=value)
317-
except yamltrip.PatchError as err:
318-
if not doc.source.strip():
319-
# Empty document: bootstrap with a block-style seed then upsert.
320-
# yamlpatch's Add operation requires an existing mapping node at the
321-
# target route, so it cannot add keys to a truly empty document.
322-
# We work around this by seeding with a throwaway sentinel key ("_")
323-
# to create a root mapping, inserting the real content, then removing
324-
# the sentinel. The sentinel is safe because the document is empty
325-
# (no user data to collide with).
326-
# Tracking issue to remove this workaround:
327-
# https://github.com/usethis-python/yamltrip/issues/34
328-
doc = yamltrip.loads("_: null\n")
329-
doc = doc.upsert(*keys, value=None)
330-
doc = doc.upsert(*keys, value=value)
331-
return doc.remove("_")
332-
err_msg = str(err)
333-
if (
334-
"non-mapping route" in err_msg
335-
or "expected mapping containing key" in err_msg
336-
):
337-
if exists_ok:
338-
# Remove the conflicting prefix and retry.
339-
for i in range(len(keys) - 1, 0, -1):
340-
if tuple(keys[:i]) in doc:
341-
doc = doc.prune_remove(*keys[:i])
342-
return _upsert_safe(doc, keys, value, exists_ok=exists_ok)
343-
# Trying to add a key under a non-mapping node.
344-
# Find the longest existing prefix to report.
345-
for i in range(len(keys) - 1, 0, -1):
346-
if tuple(keys[:i]) in doc:
347-
msg = f"Configuration value '{print_keys(list(keys[:i]))}' is already set."
348-
raise YAMLValueAlreadySetError(msg) from err
349-
raise
316+
except yamltrip.RoutingError as err:
317+
# A key along the path maps to a non-mapping node. Find the longest
318+
# existing prefix: the node we collided with.
319+
for i in range(len(keys) - 1, 0, -1):
320+
if tuple(keys[:i]) in doc:
321+
if exists_ok:
322+
# Overwrite: remove the conflicting prefix and retry.
323+
doc = doc.prune_remove(*keys[:i])
324+
return _upsert_safe(doc, keys, value, exists_ok=exists_ok)
325+
msg = f"Configuration value '{print_keys(list(keys[:i]))}' is already set."
326+
raise YAMLValueAlreadySetError(msg) from err
327+
# A RoutingError always collides with an existing non-mapping prefix, so
328+
# the loop above must have returned or raised.
329+
msg = "RoutingError did not pass through an existing prefix."
330+
raise AssertionError(msg) from err

tests/usethis/_file/yaml/test_yaml_io_.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,54 @@ def relative_path(self) -> Path:
796796
assert isinstance(manager._content, YAMLDocument)
797797
assert manager._content.doc.root == {"items": ["a", "b"]}
798798

799+
def test_complex_value_clash_with_non_mapping(self, tmp_path: Path):
800+
# Arrange
801+
class MyYAMLFileManager(YAMLFileManager):
802+
@property
803+
@override
804+
def relative_path(self) -> Path:
805+
return Path("my_yaml_file.yaml")
806+
807+
(tmp_path / "my_yaml_file.yaml").write_text("outer: value\n")
808+
809+
with change_cwd(tmp_path), MyYAMLFileManager() as manager:
810+
manager.read_file()
811+
812+
# Act, Assert
813+
with pytest.raises(
814+
YAMLValueAlreadySetError,
815+
match=r"Configuration value 'outer' is already set.",
816+
):
817+
manager.set_value(
818+
keys=["outer", "inner"],
819+
value=["a", "b"],
820+
exists_ok=False,
821+
)
822+
823+
def test_complex_value_clash_overwrite(self, tmp_path: Path):
824+
# Arrange
825+
class MyYAMLFileManager(YAMLFileManager):
826+
@property
827+
@override
828+
def relative_path(self) -> Path:
829+
return Path("my_yaml_file.yaml")
830+
831+
(tmp_path / "my_yaml_file.yaml").write_text("outer: value\n")
832+
833+
with change_cwd(tmp_path), MyYAMLFileManager() as manager:
834+
manager.read_file()
835+
836+
# Act
837+
manager.set_value(
838+
keys=["outer", "inner"],
839+
value=["a", "b"],
840+
exists_ok=True,
841+
)
842+
843+
# Assert
844+
assert isinstance(manager._content, YAMLDocument)
845+
assert manager._content.doc.root == {"outer": {"inner": ["a", "b"]}}
846+
799847
class TestDelItem:
800848
def test_delete_single_item(self, tmp_path: Path):
801849
# Arrange

uv.lock

Lines changed: 13 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)