Skip to content

Commit b556f8d

Browse files
adding deletion and pruning
1 parent a450ade commit b556f8d

5 files changed

Lines changed: 215 additions & 18 deletions

File tree

diffs/test_database.sqlite/diff-v1-to-v2.diff

Lines changed: 0 additions & 9 deletions
This file was deleted.

diffs/test_database.sqlite/diff-v2-to-v3.diff

Lines changed: 0 additions & 9 deletions
This file was deleted.

src/datamanager/__main__.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,110 @@ def _rollback_interactive(ctx: typer.Context) -> None:
496496
pass
497497

498498

499+
@app.command()
500+
def delete(
501+
ctx: typer.Context,
502+
name: str = typer.Argument(..., help="The logical name of the dataset to delete."),
503+
) -> None:
504+
"""
505+
Marks a dataset for permanent deletion.
506+
This action is finalized via a Pull Request and CI/CD pipeline.
507+
"""
508+
console.print(f"🗑️ Preparing deletion for [bold red]{name}[/].")
509+
510+
# Verify the dataset exists before proceeding
511+
if not manifest.get_dataset(name):
512+
console.print(f"[red]Error: Dataset '{name}' not found.[/]")
513+
raise typer.Exit(1)
514+
515+
console.print(
516+
"[bold yellow]WARNING:[/] This will propose the [underline]permanent deletion[/] of the dataset and all its version history from Cloudflare R2."
517+
)
518+
519+
# Use a text prompt for strong confirmation
520+
confirmation = questionary.text(
521+
f"To confirm, please type the name of the dataset ({name}):"
522+
).ask()
523+
524+
if confirmation != name:
525+
console.print("Confirmation failed. Deletion cancelled.")
526+
return
527+
528+
# Mark the dataset for deletion in the manifest
529+
if manifest.mark_for_deletion(name):
530+
console.print(
531+
f"\n[bold green]✅ Dataset '{name}' has been marked for deletion.[/]"
532+
)
533+
console.print(
534+
"\nNext steps:\n"
535+
f" 1. [cyan]git add {settings.manifest_file}[/]\n"
536+
f' 2. [cyan]git commit -m "chore: Mark {name} for deletion"[/]\n'
537+
" 3. [cyan]git push[/]\n"
538+
" 4. Open a Pull Request to finalize the deletion."
539+
)
540+
else:
541+
# This case should be caught by the check above, but is here for safety
542+
console.print(f"[red]Error: Could not mark '{name}' for deletion.[/]")
543+
raise typer.Exit(1)
544+
545+
546+
@app.command()
547+
def prune_versions(
548+
ctx: typer.Context,
549+
name: str = typer.Argument(..., help="The logical name of the dataset to prune."),
550+
keep: int = typer.Option(
551+
..., "--keep", "-k", help="The number of most recent versions to keep."
552+
),
553+
) -> None:
554+
"""Marks old versions of a dataset for permanent deletion."""
555+
console.print(f"🔪 Preparing to prune old versions of [cyan]{name}[/]...")
556+
557+
dataset = manifest.get_dataset(name)
558+
if not dataset:
559+
console.print(f"[red]Error: Dataset '{name}' not found.[/]")
560+
raise typer.Exit(1)
561+
562+
history = dataset.get("history", [])
563+
if len(history) <= keep:
564+
console.print(
565+
f"✅ No action needed. Dataset has {len(history)} version(s), which is not more than the {keep} to keep."
566+
)
567+
return
568+
569+
versions_to_keep = [entry["version"] for entry in history[:keep]]
570+
versions_to_delete = [entry["version"] for entry in history[keep:]]
571+
572+
console.print(
573+
f"You have chosen to keep the [bold green]{keep}[/] most recent version(s):"
574+
)
575+
for v in versions_to_keep:
576+
console.print(f" - [green]{v}[/]")
577+
578+
console.print(
579+
f"\nThe following [bold red]{len(versions_to_delete)}[/] older version(s) will be marked for permanent deletion:"
580+
)
581+
for v in versions_to_delete:
582+
console.print(f" - [red]{v}[/]")
583+
584+
proceed = _ask_confirm(ctx, "\nDo you want to continue?", default=False)
585+
if not proceed:
586+
console.print("Pruning cancelled.")
587+
return
588+
589+
# Mark the versions for deletion in the manifest
590+
manifest.mark_versions_for_deletion(name, versions_to_delete)
591+
console.print(
592+
f"\n[bold green]✅ {len(versions_to_delete)} version(s) have been marked for deletion.[/]"
593+
)
594+
console.print(
595+
"\nNext steps:\n"
596+
f" 1. [cyan]git add {settings.manifest_file}[/]\n"
597+
f' 2. [cyan]git commit -m "chore: Prune old versions of {name}, keeping {keep}"[/]\n'
598+
" 3. [cyan]git push[/]\n"
599+
" 4. Open a Pull Request to finalize the deletion."
600+
)
601+
602+
499603
@app.callback(invoke_without_command=True)
500604
def main(ctx: typer.Context, no_prompt: bool = COMMON_OPTIONS["no_prompt"]) -> None:
501605
"""
@@ -521,6 +625,8 @@ def main(ctx: typer.Context, no_prompt: bool = COMMON_OPTIONS["no_prompt"]) -> N
521625
"Prepare a dataset for release": _prepare_interactive,
522626
"Pull a dataset version": _pull_interactive,
523627
"Rollback a dataset to a previous version": _rollback_interactive,
628+
"Delete a dataset": delete,
629+
"Prune old dataset versions": prune_versions,
524630
"Exit": "exit",
525631
}
526632

src/datamanager/manifest.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,39 @@ def update_dataset(name: str, updated_dataset: dict[str, Any]) -> None:
210210
data[i] = updated_dataset
211211
break
212212
write_manifest(data)
213+
214+
215+
def mark_for_deletion(name: str) -> bool:
216+
"""
217+
Finds a dataset and adds a 'status: pending-deletion' flag.
218+
Returns True if the dataset was found and marked, False otherwise.
219+
"""
220+
data = read_manifest()
221+
dataset_found = False
222+
for item in data:
223+
if item.get("fileName") == name:
224+
item["status"] = "pending-deletion"
225+
dataset_found = True
226+
break
227+
if dataset_found:
228+
write_manifest(data)
229+
return dataset_found
230+
231+
232+
def mark_versions_for_deletion(name: str, versions_to_delete: list[str]) -> bool:
233+
"""
234+
Finds a dataset and adds a 'status: pending-deletion' flag to specific
235+
history entries. Returns True on success.
236+
"""
237+
data = read_manifest()
238+
dataset_found = False
239+
for item in data:
240+
if item.get("fileName") == name:
241+
dataset_found = True
242+
for entry in item.get("history", []):
243+
if entry.get("version") in versions_to_delete:
244+
entry["status"] = "pending-deletion"
245+
break
246+
if dataset_found:
247+
write_manifest(data)
248+
return dataset_found

tests/test_main.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,3 +343,76 @@ def test_rollback_user_cancel(test_repo: Path, mocker: MockerFixture) -> None:
343343
# Verify the manifest file was not changed
344344
final_manifest = manifest.read_manifest()
345345
assert final_manifest == original_manifest
346+
347+
348+
def test_delete_success(test_repo: Path, mocker: MockerFixture) -> None:
349+
"""Test successfully marking a dataset for deletion."""
350+
os.chdir(test_repo)
351+
# Mock the text confirmation to succeed
352+
mocker.patch(
353+
"questionary.text",
354+
return_value=mocker.Mock(ask=mocker.Mock(return_value="core-dataset.sqlite")),
355+
)
356+
357+
result = runner.invoke(app, ["delete", "core-dataset.sqlite"])
358+
359+
assert result.exit_code == 0, result.stdout
360+
assert "has been marked for deletion" in result.stdout
361+
362+
# Verify the manifest was updated with the status flag
363+
dataset = manifest.get_dataset("core-dataset.sqlite")
364+
assert dataset is not None
365+
assert dataset.get("status") == "pending-deletion"
366+
367+
368+
def test_delete_confirmation_failed(test_repo: Path, mocker: MockerFixture) -> None:
369+
"""Test that deletion is cancelled if the typed confirmation is wrong."""
370+
os.chdir(test_repo)
371+
# Mock the text confirmation to fail
372+
mocker.patch(
373+
"questionary.text",
374+
return_value=mocker.Mock(ask=mocker.Mock(return_value="wrong-name")),
375+
)
376+
original_manifest = manifest.read_manifest()
377+
378+
result = runner.invoke(app, ["delete", "core-dataset.sqlite"])
379+
380+
assert result.exit_code == 0, result.stdout
381+
assert "Confirmation failed. Deletion cancelled." in result.stdout
382+
383+
# Verify the manifest was NOT changed
384+
final_manifest = manifest.read_manifest()
385+
assert final_manifest == original_manifest
386+
387+
388+
def test_prune_versions_success(test_repo: Path, mocker: MockerFixture) -> None:
389+
"""Test successfully marking old versions for deletion."""
390+
os.chdir(test_repo)
391+
mocker.patch("datamanager.__main__._ask_confirm", return_value=True)
392+
393+
# Act: Keep the latest 1 version, which should mark v1 for deletion
394+
result = runner.invoke(
395+
app, ["prune-versions", "core-dataset.sqlite", "--keep", "1"]
396+
)
397+
398+
assert result.exit_code == 0, result.stdout
399+
assert "1 version(s) have been marked for deletion" in result.stdout
400+
401+
# Assert: Check that the v1 entry in the manifest is now marked
402+
dataset = manifest.get_dataset("core-dataset.sqlite")
403+
assert dataset is not None
404+
v2_entry = dataset["history"][0]
405+
v1_entry = dataset["history"][1]
406+
assert "status" not in v2_entry # v2 is kept, should not be marked
407+
assert v1_entry.get("status") == "pending-deletion"
408+
409+
410+
def test_prune_versions_no_op(test_repo: Path, mocker: MockerFixture) -> None:
411+
"""Test pruning when the number to keep is >= the number of versions."""
412+
os.chdir(test_repo)
413+
result = runner.invoke(
414+
app, ["prune-versions", "core-dataset.sqlite", "--keep", "5"]
415+
)
416+
417+
assert result.exit_code == 0, result.stdout
418+
assert "No action needed" in result.stdout

0 commit comments

Comments
 (0)