dotx 3.3.0__tar.gz → 3.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {dotx-3.3.0/src/dotx.egg-info → dotx-3.3.1}/PKG-INFO +1 -1
  2. {dotx-3.3.0 → dotx-3.3.1}/pyproject.toml +1 -1
  3. dotx-3.3.1/src/dotx/commands/uninstall_cmd.py +155 -0
  4. {dotx-3.3.0 → dotx-3.3.1/src/dotx.egg-info}/PKG-INFO +1 -1
  5. {dotx-3.3.0 → dotx-3.3.1}/tests/test_cli.py +66 -0
  6. dotx-3.3.0/src/dotx/commands/uninstall_cmd.py +0 -75
  7. {dotx-3.3.0 → dotx-3.3.1}/LICENSE +0 -0
  8. {dotx-3.3.0 → dotx-3.3.1}/MANIFEST.in +0 -0
  9. {dotx-3.3.0 → dotx-3.3.1}/README.md +0 -0
  10. {dotx-3.3.0 → dotx-3.3.1}/setup.cfg +0 -0
  11. {dotx-3.3.0 → dotx-3.3.1}/src/dotx/__init__.py +0 -0
  12. {dotx-3.3.0 → dotx-3.3.1}/src/dotx/always-create +0 -0
  13. {dotx-3.3.0 → dotx-3.3.1}/src/dotx/cli.py +0 -0
  14. {dotx-3.3.0 → dotx-3.3.1}/src/dotx/commands/__init__.py +0 -0
  15. {dotx-3.3.0 → dotx-3.3.1}/src/dotx/commands/database.py +0 -0
  16. {dotx-3.3.0 → dotx-3.3.1}/src/dotx/commands/install_cmd.py +0 -0
  17. {dotx-3.3.0 → dotx-3.3.1}/src/dotx/commands/path_cmd.py +0 -0
  18. {dotx-3.3.0 → dotx-3.3.1}/src/dotx/commands/progress.py +0 -0
  19. {dotx-3.3.0 → dotx-3.3.1}/src/dotx/database.py +0 -0
  20. {dotx-3.3.0 → dotx-3.3.1}/src/dotx/dotxignore +0 -0
  21. {dotx-3.3.0 → dotx-3.3.1}/src/dotx/hierarchy.py +0 -0
  22. {dotx-3.3.0 → dotx-3.3.1}/src/dotx/ignore.py +0 -0
  23. {dotx-3.3.0 → dotx-3.3.1}/src/dotx/install.py +0 -0
  24. {dotx-3.3.0 → dotx-3.3.1}/src/dotx/installed-schema.sql +0 -0
  25. {dotx-3.3.0 → dotx-3.3.1}/src/dotx/options.py +0 -0
  26. {dotx-3.3.0 → dotx-3.3.1}/src/dotx/plan.py +0 -0
  27. {dotx-3.3.0 → dotx-3.3.1}/src/dotx/uninstall.py +0 -0
  28. {dotx-3.3.0 → dotx-3.3.1}/src/dotx.egg-info/SOURCES.txt +0 -0
  29. {dotx-3.3.0 → dotx-3.3.1}/src/dotx.egg-info/dependency_links.txt +0 -0
  30. {dotx-3.3.0 → dotx-3.3.1}/src/dotx.egg-info/entry_points.txt +0 -0
  31. {dotx-3.3.0 → dotx-3.3.1}/src/dotx.egg-info/requires.txt +0 -0
  32. {dotx-3.3.0 → dotx-3.3.1}/src/dotx.egg-info/top_level.txt +0 -0
  33. {dotx-3.3.0 → dotx-3.3.1}/tests/test_always_create.py +0 -0
  34. {dotx-3.3.0 → dotx-3.3.1}/tests/test_cli_database.py +0 -0
  35. {dotx-3.3.0 → dotx-3.3.1}/tests/test_ignore.py +0 -0
  36. {dotx-3.3.0 → dotx-3.3.1}/tests/test_ignore_rules.py +0 -0
  37. {dotx-3.3.0 → dotx-3.3.1}/tests/test_install.py +0 -0
  38. {dotx-3.3.0 → dotx-3.3.1}/tests/test_options.py +0 -0
  39. {dotx-3.3.0 → dotx-3.3.1}/tests/test_path_which.py +0 -0
  40. {dotx-3.3.0 → dotx-3.3.1}/tests/test_plan.py +0 -0
  41. {dotx-3.3.0 → dotx-3.3.1}/tests/test_uninstall.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotx
3
- Version: 3.3.0
3
+ Version: 3.3.1
4
4
  Summary: A command-line tool to install a link-farm to your dotfiles
5
5
  Author-email: Wolf <Wolf@zv.cx>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dotx"
3
- version = "3.3.0"
3
+ version = "3.3.1"
4
4
  description = "A command-line tool to install a link-farm to your dotfiles"
5
5
  authors = [
6
6
  { name = "Wolf", email = "Wolf@zv.cx" }
@@ -0,0 +1,155 @@
1
+ """Uninstall command for dotx CLI."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from loguru import logger
8
+ from rich.console import Console
9
+
10
+ from dotx.commands.progress import execute_plans_with_progress
11
+ from dotx.database import InstallationDB
12
+ from dotx.options import is_dry_run, is_verbose_mode
13
+ from dotx.plan import Action, Plan, extract_plan, log_extracted_plan
14
+ from dotx.uninstall import plan_uninstall
15
+
16
+
17
+ def _uninstall_from_database(
18
+ package_path: Path,
19
+ db: InstallationDB,
20
+ console: Console,
21
+ dry_run: bool,
22
+ verbose: bool,
23
+ ) -> int:
24
+ """
25
+ Uninstall a package using database records when source directory is missing.
26
+
27
+ Returns the number of symlinks removed.
28
+ """
29
+ package_root = package_path.parent
30
+ package_name = package_path.name
31
+
32
+ installations = db.get_installations(package_root, package_name)
33
+
34
+ if not installations:
35
+ console.print(f"[yellow]No installations found for {package_name}[/yellow]")
36
+ return 0
37
+
38
+ removed_count = 0
39
+
40
+ for entry in installations:
41
+ target_path = Path(entry["target_path"])
42
+
43
+ if dry_run:
44
+ if target_path.is_symlink():
45
+ console.print(f" rm {target_path}")
46
+ removed_count += 1
47
+ elif verbose:
48
+ console.print(f" [dim]skip (not a symlink): {target_path}[/dim]")
49
+ else:
50
+ if target_path.is_symlink():
51
+ try:
52
+ target_path.unlink()
53
+ db.remove_installation(target_path)
54
+ removed_count += 1
55
+ if verbose:
56
+ console.print(f" Removed: {target_path}")
57
+ except OSError as e:
58
+ logger.warning(f"Failed to remove {target_path}: {e}")
59
+ else:
60
+ # Not a symlink - just remove from database
61
+ db.remove_installation(target_path)
62
+ if verbose:
63
+ console.print(f" [dim]Cleaned db entry (not a symlink): {target_path}[/dim]")
64
+
65
+ return removed_count
66
+
67
+
68
+ def register_command(app: typer.Typer):
69
+ """Register the uninstall command with the Typer app."""
70
+
71
+ @app.command()
72
+ def uninstall(
73
+ ctx: typer.Context,
74
+ sources: Annotated[
75
+ list[Path],
76
+ typer.Argument(
77
+ help="Source package directories to uninstall (can be deleted)",
78
+ ),
79
+ ],
80
+ ):
81
+ """
82
+ Uninstall source packages from target directory.
83
+
84
+ If the source package directory still exists, uninstalls by scanning it.
85
+ If the source has been deleted, uninstalls using database records.
86
+ """
87
+ logger.info("uninstall starting")
88
+ console = Console()
89
+ verbose = is_verbose_mode(ctx)
90
+ dry_run = is_dry_run(ctx)
91
+
92
+ # Get target from options
93
+ target_path = Path(ctx.obj.get("TARGET", Path.home())) if ctx.obj else Path.home()
94
+
95
+ if not sources:
96
+ logger.info("uninstall finished (no sources)")
97
+ return
98
+
99
+ # Partition sources using set math
100
+ sources_set = set(sources)
101
+ existing_sources = {s for s in sources_set if s.exists() and s.is_dir()}
102
+ missing_sources = sources_set - existing_sources
103
+
104
+ total_removed = 0
105
+
106
+ # Handle existing sources with plan-based uninstall
107
+ if existing_sources:
108
+ plans: list[tuple[Path, Plan]] = []
109
+ for source_package in existing_sources:
110
+ plan: Plan = plan_uninstall(source_package, target_path)
111
+ log_extracted_plan(
112
+ plan,
113
+ description=f"Actual plan to uninstall {source_package}",
114
+ actions_to_extract={Action.UNLINK},
115
+ )
116
+ plans.append((source_package, plan))
117
+
118
+ with InstallationDB() as db:
119
+ execute_plans_with_progress(
120
+ plans,
121
+ target_path,
122
+ {Action.UNLINK},
123
+ "Uninstalling",
124
+ console,
125
+ verbose,
126
+ db,
127
+ )
128
+
129
+ total_removed += sum(
130
+ len(extract_plan(plan, {Action.UNLINK}))
131
+ for _, plan in plans
132
+ )
133
+
134
+ # Handle missing sources with database-based uninstall
135
+ if missing_sources:
136
+ if dry_run:
137
+ console.print("\n[yellow][DRY RUN] Would execute the equivalent of:[/yellow]")
138
+
139
+ with InstallationDB() as db:
140
+ for source_package in missing_sources:
141
+ if verbose or dry_run:
142
+ console.print(f"\n[cyan]Uninstalling {source_package.name} (source deleted)...[/cyan]")
143
+
144
+ removed = _uninstall_from_database(
145
+ source_package, db, console, dry_run, verbose
146
+ )
147
+ total_removed += removed
148
+
149
+ # Show summary
150
+ if dry_run:
151
+ console.print(f"\n[yellow][DRY RUN] Would remove {total_removed} symlink(s) from {len(sources)} package(s)[/yellow]")
152
+ else:
153
+ console.print(f"\n[green]✓ Removed {total_removed} symlink(s) from {len(sources)} package(s)[/green]")
154
+
155
+ logger.info("uninstall finished")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotx
3
- Version: 3.3.0
3
+ Version: 3.3.1
4
4
  Summary: A command-line tool to install a link-farm to your dotfiles
5
5
  Author-email: Wolf <Wolf@zv.cx>
6
6
  License-Expression: MIT
@@ -389,3 +389,69 @@ def test_cli_xdg_mode_install_and_uninstall_roundtrip(tmp_path, monkeypatch, iso
389
389
 
390
390
  # File should be removed (not accessible anymore)
391
391
  assert not config_file.exists(), "File should be removed after uninstall"
392
+
393
+
394
+ def test_cli_uninstall_with_deleted_source(tmp_path, isolated_db):
395
+ """Test that uninstall works even when source package has been deleted."""
396
+ import shutil
397
+
398
+ source = tmp_path / "source"
399
+ source.mkdir()
400
+ target = tmp_path / "target"
401
+ target.mkdir()
402
+
403
+ # Create source files
404
+ (source / "file1.txt").write_text("content1")
405
+ (source / "file2.txt").write_text("content2")
406
+
407
+ runner = CliRunner()
408
+
409
+ # Install the package
410
+ result = runner.invoke(app, [f"--target={target}", "install", str(source)])
411
+ assert result.exit_code == 0, f"Install failed: {result.output}"
412
+
413
+ # Verify files are installed
414
+ assert (target / "file1.txt").is_symlink()
415
+ assert (target / "file2.txt").is_symlink()
416
+
417
+ # Delete the source package
418
+ shutil.rmtree(source)
419
+ assert not source.exists()
420
+
421
+ # Uninstall should still work using database (use --verbose to see "source deleted" message)
422
+ result = runner.invoke(app, [f"--target={target}", "--verbose", "uninstall", str(source)])
423
+ assert result.exit_code == 0, f"Uninstall failed: {result.output}"
424
+ assert "source deleted" in result.output.lower()
425
+
426
+ # Symlinks should be removed (not just broken)
427
+ assert not (target / "file1.txt").is_symlink()
428
+ assert not (target / "file2.txt").is_symlink()
429
+
430
+
431
+ def test_cli_uninstall_deleted_source_dry_run(tmp_path, isolated_db):
432
+ """Test dry-run uninstall with deleted source shows correct output."""
433
+ import shutil
434
+
435
+ source = tmp_path / "source"
436
+ source.mkdir()
437
+ target = tmp_path / "target"
438
+ target.mkdir()
439
+
440
+ # Create and install
441
+ (source / "file1.txt").write_text("content")
442
+
443
+ runner = CliRunner()
444
+ result = runner.invoke(app, [f"--target={target}", "install", str(source)])
445
+ assert result.exit_code == 0
446
+
447
+ # Delete source
448
+ shutil.rmtree(source)
449
+
450
+ # Dry-run uninstall
451
+ result = runner.invoke(app, [f"--target={target}", "--dry-run", "uninstall", str(source)])
452
+ assert result.exit_code == 0
453
+ assert "[DRY RUN]" in result.output
454
+ assert "rm" in result.output
455
+
456
+ # Symlink should still exist after dry-run (even though it's broken)
457
+ assert (target / "file1.txt").is_symlink()
@@ -1,75 +0,0 @@
1
- """Uninstall command for dotx CLI."""
2
-
3
- from pathlib import Path
4
- from typing import Annotated
5
-
6
- import typer
7
- from loguru import logger
8
- from rich.console import Console
9
-
10
- from dotx.commands.progress import execute_plans_with_progress
11
- from dotx.database import InstallationDB
12
- from dotx.options import is_dry_run, is_verbose_mode
13
- from dotx.plan import Action, Plan, extract_plan, log_extracted_plan
14
- from dotx.uninstall import plan_uninstall
15
-
16
-
17
- def register_command(app: typer.Typer):
18
- """Register the uninstall command with the Typer app."""
19
-
20
- @app.command()
21
- def uninstall(
22
- ctx: typer.Context,
23
- sources: Annotated[
24
- list[Path],
25
- typer.Argument(
26
- help="Source package directories to uninstall",
27
- exists=True,
28
- file_okay=False,
29
- dir_okay=True,
30
- readable=True,
31
- ),
32
- ],
33
- ):
34
- """Uninstall source packages from target directory."""
35
- logger.info("uninstall starting")
36
- console = Console()
37
- verbose = is_verbose_mode(ctx)
38
-
39
- # Get target from options
40
- target_path = Path(ctx.obj.get("TARGET", Path.home())) if ctx.obj else Path.home()
41
-
42
- if sources:
43
- plans: list[tuple[Path, Plan]] = []
44
- for source_package in sources:
45
- plan: Plan = plan_uninstall(source_package, target_path)
46
- log_extracted_plan(
47
- plan,
48
- description=f"Actual plan to uninstall {source_package}",
49
- actions_to_extract={Action.UNLINK},
50
- )
51
- plans.append((source_package, plan))
52
-
53
- # Open database and execute all plans with progress
54
- with InstallationDB() as db:
55
- execute_plans_with_progress(
56
- plans,
57
- target_path,
58
- {Action.UNLINK},
59
- "Uninstalling",
60
- console,
61
- verbose,
62
- db,
63
- )
64
-
65
- # Show summary
66
- total_removed = sum(
67
- len(extract_plan(plan, {Action.UNLINK}))
68
- for _, plan in plans
69
- )
70
- if is_dry_run(ctx):
71
- console.print(f"\n[yellow][DRY RUN] Would remove {total_removed} symlink(s) from {len(sources)} package(s)[/yellow]")
72
- else:
73
- console.print(f"\n[green]✓ Removed {total_removed} symlink(s) from {len(sources)} package(s)[/green]")
74
-
75
- logger.info("uninstall finished")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes