dotx 3.3.0__tar.gz → 3.3.2__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.2}/PKG-INFO +1 -1
  2. {dotx-3.3.0 → dotx-3.3.2}/pyproject.toml +1 -1
  3. {dotx-3.3.0 → dotx-3.3.2}/src/dotx/commands/database.py +17 -4
  4. {dotx-3.3.0 → dotx-3.3.2}/src/dotx/commands/install_cmd.py +11 -4
  5. dotx-3.3.2/src/dotx/commands/uninstall_cmd.py +155 -0
  6. {dotx-3.3.0 → dotx-3.3.2/src/dotx.egg-info}/PKG-INFO +1 -1
  7. {dotx-3.3.0 → dotx-3.3.2}/tests/test_cli.py +66 -0
  8. {dotx-3.3.0 → dotx-3.3.2}/tests/test_cli_database.py +42 -1
  9. dotx-3.3.0/src/dotx/commands/uninstall_cmd.py +0 -75
  10. {dotx-3.3.0 → dotx-3.3.2}/LICENSE +0 -0
  11. {dotx-3.3.0 → dotx-3.3.2}/MANIFEST.in +0 -0
  12. {dotx-3.3.0 → dotx-3.3.2}/README.md +0 -0
  13. {dotx-3.3.0 → dotx-3.3.2}/setup.cfg +0 -0
  14. {dotx-3.3.0 → dotx-3.3.2}/src/dotx/__init__.py +0 -0
  15. {dotx-3.3.0 → dotx-3.3.2}/src/dotx/always-create +0 -0
  16. {dotx-3.3.0 → dotx-3.3.2}/src/dotx/cli.py +0 -0
  17. {dotx-3.3.0 → dotx-3.3.2}/src/dotx/commands/__init__.py +0 -0
  18. {dotx-3.3.0 → dotx-3.3.2}/src/dotx/commands/path_cmd.py +0 -0
  19. {dotx-3.3.0 → dotx-3.3.2}/src/dotx/commands/progress.py +0 -0
  20. {dotx-3.3.0 → dotx-3.3.2}/src/dotx/database.py +0 -0
  21. {dotx-3.3.0 → dotx-3.3.2}/src/dotx/dotxignore +0 -0
  22. {dotx-3.3.0 → dotx-3.3.2}/src/dotx/hierarchy.py +0 -0
  23. {dotx-3.3.0 → dotx-3.3.2}/src/dotx/ignore.py +0 -0
  24. {dotx-3.3.0 → dotx-3.3.2}/src/dotx/install.py +0 -0
  25. {dotx-3.3.0 → dotx-3.3.2}/src/dotx/installed-schema.sql +0 -0
  26. {dotx-3.3.0 → dotx-3.3.2}/src/dotx/options.py +0 -0
  27. {dotx-3.3.0 → dotx-3.3.2}/src/dotx/plan.py +0 -0
  28. {dotx-3.3.0 → dotx-3.3.2}/src/dotx/uninstall.py +0 -0
  29. {dotx-3.3.0 → dotx-3.3.2}/src/dotx.egg-info/SOURCES.txt +0 -0
  30. {dotx-3.3.0 → dotx-3.3.2}/src/dotx.egg-info/dependency_links.txt +0 -0
  31. {dotx-3.3.0 → dotx-3.3.2}/src/dotx.egg-info/entry_points.txt +0 -0
  32. {dotx-3.3.0 → dotx-3.3.2}/src/dotx.egg-info/requires.txt +0 -0
  33. {dotx-3.3.0 → dotx-3.3.2}/src/dotx.egg-info/top_level.txt +0 -0
  34. {dotx-3.3.0 → dotx-3.3.2}/tests/test_always_create.py +0 -0
  35. {dotx-3.3.0 → dotx-3.3.2}/tests/test_ignore.py +0 -0
  36. {dotx-3.3.0 → dotx-3.3.2}/tests/test_ignore_rules.py +0 -0
  37. {dotx-3.3.0 → dotx-3.3.2}/tests/test_install.py +0 -0
  38. {dotx-3.3.0 → dotx-3.3.2}/tests/test_options.py +0 -0
  39. {dotx-3.3.0 → dotx-3.3.2}/tests/test_path_which.py +0 -0
  40. {dotx-3.3.0 → dotx-3.3.2}/tests/test_plan.py +0 -0
  41. {dotx-3.3.0 → dotx-3.3.2}/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.2
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.2"
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" }
@@ -85,7 +85,7 @@ def register_commands(app: typer.Typer):
85
85
  packages = db.get_all_packages()
86
86
 
87
87
  if not packages:
88
- console.print("[yellow]No packages installed.[/yellow]")
88
+ console.print("[yellow]No packages in database. Run `dotx sync --package-root <dir>` to rebuild from existing symlinks.[/yellow]")
89
89
  return
90
90
 
91
91
  if as_commands:
@@ -100,17 +100,30 @@ def register_commands(app: typer.Typer):
100
100
  table.add_column("Package", style="cyan", no_wrap=True)
101
101
  table.add_column("Files", justify="right", style="magenta")
102
102
  table.add_column("Last Install", style="green")
103
+ table.add_column("Status", style="green")
103
104
 
105
+ missing_count = 0
104
106
  for pkg in packages:
105
107
  # Show full package path (package_root / package_name) as documented
106
- package_path = str(Path(pkg["package_root"]) / pkg["package_name"])
108
+ package_path = Path(pkg["package_root"]) / pkg["package_name"]
107
109
  file_count = str(pkg["file_count"])
108
110
  latest = _format_timestamp(pkg["latest_install"])
109
- table.add_row(package_path, file_count, latest)
111
+
112
+ # Check if source still exists
113
+ if package_path.exists():
114
+ status = "[green]✓[/green]"
115
+ else:
116
+ status = "[yellow]⚠ source missing[/yellow]"
117
+ missing_count += 1
118
+
119
+ table.add_row(str(package_path), file_count, latest, status)
110
120
 
111
121
  console.print()
112
122
  console.print(table)
113
- console.print(f"\n[bold]Total: {len(packages)} package(s)[/bold]\n")
123
+ console.print(f"\n[bold]Total: {len(packages)} package(s)[/bold]")
124
+ if missing_count > 0:
125
+ console.print(f"[yellow]⚠ {missing_count} package(s) have missing source directories. Run `dotx verify` for details.[/yellow]")
126
+ console.print()
114
127
 
115
128
  logger.info("list finished")
116
129
 
@@ -99,11 +99,18 @@ def register_command(app: typer.Typer):
99
99
  if total_dirs:
100
100
  summary_parts.append(f"{total_dirs} dir(s)")
101
101
 
102
- summary = " and ".join(summary_parts) if summary_parts else "nothing"
103
- if is_dry_run(ctx):
104
- console.print(f"\n[yellow][DRY RUN] Would install {summary} from {len(sources)} package(s)[/yellow]")
102
+ if summary_parts:
103
+ summary = " and ".join(summary_parts)
104
+ if is_dry_run(ctx):
105
+ console.print(f"\n[yellow][DRY RUN] Would install {summary} from {len(sources)} package(s)[/yellow]")
106
+ else:
107
+ console.print(f"\n[green]✓ Installed {summary} from {len(sources)} package(s)[/green]")
105
108
  else:
106
- console.print(f"\n[green]✓ Installed {summary} from {len(sources)} package(s)[/green]")
109
+ # Empty package(s) - nothing to install
110
+ if is_dry_run(ctx):
111
+ console.print(f"\n[yellow][DRY RUN] Would install nothing from {len(sources)} package(s) (nothing to record in database)[/yellow]")
112
+ else:
113
+ console.print(f"\n[green]✓ Installed nothing from {len(sources)} package(s) (nothing to record in database)[/green]")
107
114
  else:
108
115
  console.print("[red]✗ Refusing to install - conflicts detected[/red]")
109
116
 
@@ -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.2
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()
@@ -301,7 +301,7 @@ def test_cli_list_no_packages(tmp_path, monkeypatch):
301
301
  result = runner.invoke(app, ["list"])
302
302
 
303
303
  assert result.exit_code == 0
304
- assert "No packages installed" in result.output
304
+ assert "No packages in database" in result.output
305
305
 
306
306
 
307
307
  def test_cli_list_with_packages(tmp_path, monkeypatch):
@@ -866,3 +866,44 @@ def test_list_shows_correct_package_names_after_install(tmp_path, monkeypatch):
866
866
  result = runner.invoke(app, ["list"])
867
867
  assert result.exit_code == 0, f"list failed: {result.output}"
868
868
  assert "2 package(s)" in result.output # Should show 2 packages
869
+
870
+
871
+ def test_cli_list_shows_missing_source_status(tmp_path, monkeypatch):
872
+ """Test 'dotx list' shows status indicator when source package is missing."""
873
+ import shutil
874
+
875
+ # Override XDG_DATA_HOME
876
+ data_dir = tmp_path / "config"
877
+ data_dir.mkdir()
878
+ monkeypatch.setenv("XDG_DATA_HOME", str(data_dir))
879
+
880
+ # Create and install packages
881
+ source1 = tmp_path / "source1"
882
+ source1.mkdir()
883
+ (source1 / "file1").write_text("content")
884
+
885
+ source2 = tmp_path / "source2"
886
+ source2.mkdir()
887
+ (source2 / "file2").write_text("content")
888
+
889
+ target = tmp_path / "target"
890
+ target.mkdir()
891
+
892
+ runner = CliRunner()
893
+
894
+ # Install both packages
895
+ result = runner.invoke(app, [f"--target={target}", "install", str(source1)])
896
+ assert result.exit_code == 0
897
+ result = runner.invoke(app, [f"--target={target}", "install", str(source2)])
898
+ assert result.exit_code == 0
899
+
900
+ # Delete one source package
901
+ shutil.rmtree(source1)
902
+ assert not source1.exists()
903
+
904
+ # List packages - should show warning for missing source
905
+ result = runner.invoke(app, ["list"])
906
+ assert result.exit_code == 0
907
+ # The warning message at the bottom should be visible
908
+ assert "1 package(s) have missing source directories" in result.output
909
+ assert "dotx verify" in result.output
@@ -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