Skip to content

Commit 4aea0a3

Browse files
committed
Add YamlDocument.delete(), remove stale version keys in freeze, complete test assertions
- yaml.py: add delete(jsonpath, field) — mirrors set() but removes the field and its child block; _delete_by_parts stops at blank lines to preserve inter-item separators; 5 unit tests added in test_yaml.py - manifest.py update_project_version: call _doc.delete() for each version key that is now empty/absent (removes stale 'revision', 'tag', 'branch') and remove the entire 'integrity' block when no hash is present; regression test added in test_manifest.py - test_add.py: add append_project_entry.assert_called_once() + update_dump.assert_called_once() to the 5 tests that previously only accessed call_args without asserting call count: suffixes_duplicate_name, interactive_branch_by_number, svn_custom_branch, svn_tag, svn_branch_by_number https://claude.ai/code/session_01Xd8EcAUkSoJo9YZimGzEuA
1 parent 8d04a36 commit 4aea0a3

5 files changed

Lines changed: 170 additions & 0 deletions

File tree

dfetch/manifest/manifest.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,9 +418,16 @@ def update_project_version(self, project: ProjectEntry) -> None:
418418
f"Updating {project.name} version field '{name}' to '{value}' in manifest"
419419
)
420420
self._doc.set(path, name, value)
421+
else:
422+
# Remove any previously-pinned key that is no longer active
423+
# (e.g. an old 'revision' when the project is now pinned by tag).
424+
self._doc.delete(path, name)
421425

422426
if project.integrity and project.integrity.hash:
423427
self._doc.set(path, "integrity.hash", project.integrity.hash)
428+
else:
429+
# Remove a stale integrity block if the project no longer carries one.
430+
self._doc.delete(path, "integrity")
424431

425432
self.__text = self._doc.dump()
426433

dfetch/util/yaml.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,60 @@ def set(self, jsonpath: str, field: str, value: str) -> None:
172172
)
173173
self._set_by_parts(target_parts, value)
174174

175+
def delete(self, jsonpath: str, field: str) -> None:
176+
"""Remove *field* from every node matched by *jsonpath*.
177+
178+
*field* may be a simple key (``"revision"``) or a top-level nested
179+
key (``"integrity"``); all child lines are removed together with the
180+
key itself. If the field is absent the call is a no-op.
181+
182+
Example::
183+
184+
doc.delete(
185+
'$.manifest.projects[?(@.name == "my-project")]',
186+
"revision",
187+
)
188+
"""
189+
steps = self._parse_jsonpath(jsonpath)
190+
191+
filter_idx = next(
192+
(i for i, s in enumerate(steps) if isinstance(s, _FilterStep)), None
193+
)
194+
195+
if filter_idx is None:
196+
parts = [s for s in steps if isinstance(s, str)] + [field]
197+
self._delete_by_parts(parts)
198+
return
199+
200+
sequence_parts = [s for s in steps[:filter_idx] if isinstance(s, str)]
201+
filter_step: _FilterStep = steps[filter_idx] # type: ignore[assignment]
202+
post_filter_parts = [s for s in steps[filter_idx + 1 :] if isinstance(s, str)]
203+
204+
root = yaml.compose("".join(self.lines))
205+
sequence_node = self._traverse_path(root, sequence_parts)
206+
if not isinstance(sequence_node, SequenceNode):
207+
return
208+
209+
# Collect targets first then delete in reverse so earlier deletions
210+
# don't shift the line indices of later ones.
211+
targets: list[list[str]] = []
212+
for item_idx, item in enumerate(sequence_node.value):
213+
if not isinstance(item, MappingNode):
214+
continue
215+
mapping = self._mapping_to_dict(item)
216+
filter_node = mapping.get(filter_step.key)
217+
if not (
218+
isinstance(filter_node, ScalarNode)
219+
and str(filter_node.value) == filter_step.value
220+
):
221+
continue
222+
targets.append(
223+
sequence_parts + [str(item_idx)] + post_filter_parts + [field]
224+
)
225+
226+
for parts in reversed(targets):
227+
self._delete_by_parts(parts)
228+
175229
def dump(self) -> str:
176230
"""Return the current document as a string."""
177231
return "".join(self.lines)
@@ -270,6 +324,31 @@ def _eval_steps(
270324
# Text-level mutation helpers #
271325
# ------------------------------------------------------------------ #
272326

327+
def _delete_by_parts(self, parts: list[str]) -> None:
328+
"""Remove the field at *parts* and all of its child lines.
329+
330+
Blank lines are treated as block separators and are never deleted,
331+
so the surrounding layout is preserved even when a field is the last
332+
item inside a sequence element.
333+
"""
334+
idx = self._find_field(parts)
335+
if idx is None:
336+
return
337+
line = self.lines[idx]
338+
field_indent = len(line) - len(line.lstrip())
339+
end_idx = idx + 1
340+
while end_idx < len(self.lines):
341+
next_stripped = self.lines[end_idx].rstrip("\n\r")
342+
if not next_stripped:
343+
break # blank line ends this field's block
344+
if next_stripped.lstrip().startswith("#"):
345+
end_idx += 1
346+
continue
347+
if len(next_stripped) - len(next_stripped.lstrip()) <= field_indent:
348+
break
349+
end_idx += 1
350+
del self.lines[idx:end_idx]
351+
273352
def _set_by_parts(self, parts: list[str], value: str) -> None:
274353
"""Locate the field at *parts* and update it, or add it if absent."""
275354
idx = self._find_field(parts)

tests/test_add.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,8 @@ def test_add_command_suffixes_duplicate_name():
236236
):
237237
Add()(_make_args("https://github.com/org/myrepo.git"))
238238

239+
fake_superproject.manifest.append_project_entry.assert_called_once()
240+
fake_superproject.manifest.update_dump.assert_called_once()
239241
entry: ProjectEntry = fake_superproject.manifest.append_project_entry.call_args[0][0]
240242
assert entry.name == "myrepo-1"
241243

@@ -317,6 +319,7 @@ def test_add_command_interactive_branch_by_number():
317319
)
318320
)
319321

322+
fake_superproject.manifest.update_dump.assert_called_once()
320323
entry: ProjectEntry = fake_superproject.manifest.append_project_entry.call_args[0][0]
321324
assert entry.branch == "dev"
322325

@@ -677,6 +680,7 @@ def test_add_command_interactive_svn_custom_branch():
677680
):
678681
Add()(_make_args(_SVN_URL, interactive=True))
679682

683+
fake_superproject.manifest.update_dump.assert_called_once()
680684
entry: ProjectEntry = fake_superproject.manifest.append_project_entry.call_args[0][0]
681685
assert entry.branch == "feature-x"
682686

@@ -708,6 +712,7 @@ def test_add_command_interactive_svn_tag():
708712
):
709713
Add()(_make_args(_SVN_URL, interactive=True))
710714

715+
fake_superproject.manifest.update_dump.assert_called_once()
711716
entry: ProjectEntry = fake_superproject.manifest.append_project_entry.call_args[0][0]
712717
assert entry.tag == "v2.0"
713718
assert entry.branch == ""
@@ -740,6 +745,7 @@ def test_add_command_interactive_svn_branch_by_number():
740745
):
741746
Add()(_make_args(_SVN_URL, interactive=True))
742747

748+
fake_superproject.manifest.update_dump.assert_called_once()
743749
entry: ProjectEntry = fake_superproject.manifest.append_project_entry.call_args[0][0]
744750
assert entry.branch == "feature-x"
745751

tests/test_manifest.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,3 +371,22 @@ def test_update_commented_out_field_is_appended_not_matched() -> None:
371371
# The live branch line keeps its value
372372
assert " branch: main" in result
373373
assert "revision:" in result
374+
375+
376+
def test_update_removes_stale_revision_when_pinned_by_tag() -> None:
377+
"""update_project_version must delete stale 'revision' when the project is now pinned by tag."""
378+
text = (
379+
"manifest:\n"
380+
" version: '0.0'\n"
381+
" projects:\n"
382+
" - name: myproject\n"
383+
" url: https://example.com\n"
384+
" revision: oldrev\n"
385+
" branch: main\n"
386+
)
387+
# Project is now pinned exclusively by tag (revision and branch are empty).
388+
project = _make_project("myproject", tag="v1.2.3")
389+
result = _update(text, project)
390+
assert "tag: v1.2.3" in result
391+
assert "revision:" not in result
392+
assert "branch:" not in result

tests/test_yaml.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,3 +313,62 @@ def test_get_field_present_in_second_project():
313313
matches = doc.get('$.manifest.projects[?(@.name == "second")].revision')
314314
assert len(matches) == 1
315315
assert matches[0].value == "abc123"
316+
317+
318+
# ---------------------------------------------------------------------------
319+
# delete()
320+
# ---------------------------------------------------------------------------
321+
322+
323+
def test_delete_removes_existing_scalar_field():
324+
doc = YamlDocument(_MANIFEST)
325+
doc.delete('$.manifest.projects[?(@.name == "myproject")]', "branch")
326+
result = doc.dump()
327+
# The myproject block should no longer contain branch
328+
myproject_end = result.index("- name: other")
329+
assert "branch:" not in result[:myproject_end]
330+
# Sibling project and other fields are untouched
331+
assert "url:" in result
332+
assert "branch: develop" in result
333+
334+
335+
def test_delete_noop_when_field_absent():
336+
doc = YamlDocument(_MANIFEST)
337+
original = doc.dump()
338+
doc.delete('$.manifest.projects[?(@.name == "myproject")]', "revision")
339+
assert doc.dump() == original
340+
341+
342+
def test_delete_noop_when_path_no_match():
343+
doc = YamlDocument(_MANIFEST)
344+
original = doc.dump()
345+
doc.delete('$.manifest.projects[?(@.name == "ghost")]', "branch")
346+
assert doc.dump() == original
347+
348+
349+
def test_delete_removes_nested_block():
350+
manifest = """\
351+
manifest:
352+
version: '0.0'
353+
projects:
354+
- name: pkg
355+
url: https://example.com
356+
vcs: archive
357+
integrity:
358+
hash: sha256:abc123
359+
"""
360+
doc = YamlDocument(manifest)
361+
doc.delete('$.manifest.projects[?(@.name == "pkg")]', "integrity")
362+
result = doc.dump()
363+
assert "integrity:" not in result
364+
assert "hash:" not in result
365+
assert "url:" in result
366+
367+
368+
def test_delete_only_affects_matched_project():
369+
doc = YamlDocument(_TWO_PROJECT_MANIFEST)
370+
doc.delete('$.manifest.projects[?(@.name == "second")]', "revision")
371+
result = doc.dump()
372+
first_end = result.index("- name: second")
373+
assert "branch: main" in result[:first_end]
374+
assert "revision" not in result[first_end:]

0 commit comments

Comments
 (0)