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.
- {dotx-3.3.0/src/dotx.egg-info → dotx-3.3.1}/PKG-INFO +1 -1
- {dotx-3.3.0 → dotx-3.3.1}/pyproject.toml +1 -1
- dotx-3.3.1/src/dotx/commands/uninstall_cmd.py +155 -0
- {dotx-3.3.0 → dotx-3.3.1/src/dotx.egg-info}/PKG-INFO +1 -1
- {dotx-3.3.0 → dotx-3.3.1}/tests/test_cli.py +66 -0
- dotx-3.3.0/src/dotx/commands/uninstall_cmd.py +0 -75
- {dotx-3.3.0 → dotx-3.3.1}/LICENSE +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/MANIFEST.in +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/README.md +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/setup.cfg +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/src/dotx/__init__.py +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/src/dotx/always-create +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/src/dotx/cli.py +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/src/dotx/commands/__init__.py +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/src/dotx/commands/database.py +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/src/dotx/commands/install_cmd.py +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/src/dotx/commands/path_cmd.py +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/src/dotx/commands/progress.py +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/src/dotx/database.py +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/src/dotx/dotxignore +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/src/dotx/hierarchy.py +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/src/dotx/ignore.py +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/src/dotx/install.py +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/src/dotx/installed-schema.sql +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/src/dotx/options.py +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/src/dotx/plan.py +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/src/dotx/uninstall.py +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/src/dotx.egg-info/SOURCES.txt +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/src/dotx.egg-info/dependency_links.txt +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/src/dotx.egg-info/entry_points.txt +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/src/dotx.egg-info/requires.txt +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/src/dotx.egg-info/top_level.txt +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/tests/test_always_create.py +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/tests/test_cli_database.py +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/tests/test_ignore.py +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/tests/test_ignore_rules.py +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/tests/test_install.py +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/tests/test_options.py +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/tests/test_path_which.py +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/tests/test_plan.py +0 -0
- {dotx-3.3.0 → dotx-3.3.1}/tests/test_uninstall.py +0 -0
|
@@ -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")
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|