dotx 3.2.0__tar.gz → 3.2.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 (40) hide show
  1. {dotx-3.2.0/src/dotx.egg-info → dotx-3.2.1}/PKG-INFO +11 -1
  2. {dotx-3.2.0 → dotx-3.2.1}/README.md +10 -0
  3. {dotx-3.2.0 → dotx-3.2.1}/pyproject.toml +1 -1
  4. {dotx-3.2.0 → dotx-3.2.1}/src/dotx/commands/install_cmd.py +5 -2
  5. {dotx-3.2.0 → dotx-3.2.1}/src/dotx/commands/uninstall_cmd.py +5 -2
  6. {dotx-3.2.0 → dotx-3.2.1}/src/dotx/install.py +2 -1
  7. {dotx-3.2.0 → dotx-3.2.1}/src/dotx/plan.py +2 -1
  8. {dotx-3.2.0 → dotx-3.2.1/src/dotx.egg-info}/PKG-INFO +11 -1
  9. {dotx-3.2.0 → dotx-3.2.1}/tests/test_cli.py +51 -0
  10. {dotx-3.2.0 → dotx-3.2.1}/tests/test_install.py +83 -0
  11. {dotx-3.2.0 → dotx-3.2.1}/LICENSE +0 -0
  12. {dotx-3.2.0 → dotx-3.2.1}/MANIFEST.in +0 -0
  13. {dotx-3.2.0 → dotx-3.2.1}/setup.cfg +0 -0
  14. {dotx-3.2.0 → dotx-3.2.1}/src/dotx/__init__.py +0 -0
  15. {dotx-3.2.0 → dotx-3.2.1}/src/dotx/always-create +0 -0
  16. {dotx-3.2.0 → dotx-3.2.1}/src/dotx/cli.py +0 -0
  17. {dotx-3.2.0 → dotx-3.2.1}/src/dotx/commands/__init__.py +0 -0
  18. {dotx-3.2.0 → dotx-3.2.1}/src/dotx/commands/database.py +0 -0
  19. {dotx-3.2.0 → dotx-3.2.1}/src/dotx/commands/path_cmd.py +0 -0
  20. {dotx-3.2.0 → dotx-3.2.1}/src/dotx/commands/progress.py +0 -0
  21. {dotx-3.2.0 → dotx-3.2.1}/src/dotx/database.py +0 -0
  22. {dotx-3.2.0 → dotx-3.2.1}/src/dotx/dotxignore +0 -0
  23. {dotx-3.2.0 → dotx-3.2.1}/src/dotx/hierarchy.py +0 -0
  24. {dotx-3.2.0 → dotx-3.2.1}/src/dotx/ignore.py +0 -0
  25. {dotx-3.2.0 → dotx-3.2.1}/src/dotx/installed-schema.sql +0 -0
  26. {dotx-3.2.0 → dotx-3.2.1}/src/dotx/options.py +0 -0
  27. {dotx-3.2.0 → dotx-3.2.1}/src/dotx/uninstall.py +0 -0
  28. {dotx-3.2.0 → dotx-3.2.1}/src/dotx.egg-info/SOURCES.txt +0 -0
  29. {dotx-3.2.0 → dotx-3.2.1}/src/dotx.egg-info/dependency_links.txt +0 -0
  30. {dotx-3.2.0 → dotx-3.2.1}/src/dotx.egg-info/entry_points.txt +0 -0
  31. {dotx-3.2.0 → dotx-3.2.1}/src/dotx.egg-info/requires.txt +0 -0
  32. {dotx-3.2.0 → dotx-3.2.1}/src/dotx.egg-info/top_level.txt +0 -0
  33. {dotx-3.2.0 → dotx-3.2.1}/tests/test_always_create.py +0 -0
  34. {dotx-3.2.0 → dotx-3.2.1}/tests/test_cli_database.py +0 -0
  35. {dotx-3.2.0 → dotx-3.2.1}/tests/test_ignore.py +0 -0
  36. {dotx-3.2.0 → dotx-3.2.1}/tests/test_ignore_rules.py +0 -0
  37. {dotx-3.2.0 → dotx-3.2.1}/tests/test_options.py +0 -0
  38. {dotx-3.2.0 → dotx-3.2.1}/tests/test_path_which.py +0 -0
  39. {dotx-3.2.0 → dotx-3.2.1}/tests/test_plan.py +0 -0
  40. {dotx-3.2.0 → dotx-3.2.1}/tests/test_uninstall.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotx
3
- Version: 3.2.0
3
+ Version: 3.2.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
@@ -58,6 +58,16 @@ purpose tool. It's made for installing a link-farm to any kind of package from
58
58
  it for other purposes, but it's tuned for its goal. It does the renaming task if you want it, but if your source files
59
59
  are named simply `.bashrc` it works just as well.
60
60
 
61
+ ### How to install
62
+
63
+ It's an ordinary PyPI-supplied Python package providing CLI entry points, so you can install it with `pip` or any other tool you like. I think the best way to install it is this:
64
+
65
+ ```bash
66
+ uv tool install dotx
67
+ ```
68
+
69
+ The current version is: v3.2.0
70
+
61
71
  ### The user interface
62
72
  ```
63
73
  Usage: dotx [OPTIONS] COMMAND [ARGS]...
@@ -30,6 +30,16 @@ purpose tool. It's made for installing a link-farm to any kind of package from
30
30
  it for other purposes, but it's tuned for its goal. It does the renaming task if you want it, but if your source files
31
31
  are named simply `.bashrc` it works just as well.
32
32
 
33
+ ### How to install
34
+
35
+ It's an ordinary PyPI-supplied Python package providing CLI entry points, so you can install it with `pip` or any other tool you like. I think the best way to install it is this:
36
+
37
+ ```bash
38
+ uv tool install dotx
39
+ ```
40
+
41
+ The current version is: v3.2.0
42
+
33
43
  ### The user interface
34
44
  ```
35
45
  Usage: dotx [OPTIONS] COMMAND [ARGS]...
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dotx"
3
- version = "3.2.0"
3
+ version = "3.2.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" }
@@ -10,7 +10,7 @@ from rich.console import Console
10
10
  from dotx.commands.progress import execute_plans_with_progress
11
11
  from dotx.database import InstallationDB
12
12
  from dotx.install import plan_install
13
- from dotx.options import is_verbose_mode
13
+ from dotx.options import is_dry_run, is_verbose_mode
14
14
  from dotx.plan import Action, Plan, extract_plan, log_extracted_plan
15
15
 
16
16
 
@@ -92,7 +92,10 @@ def register_command(app: typer.Typer):
92
92
  summary_parts.append(f"{total_dirs} dir(s)")
93
93
 
94
94
  summary = " and ".join(summary_parts) if summary_parts else "nothing"
95
- console.print(f"\n[green]✓ Installed {summary} from {len(sources)} package(s)[/green]")
95
+ if is_dry_run(ctx):
96
+ console.print(f"\n[yellow][DRY RUN] Would install {summary} from {len(sources)} package(s)[/yellow]")
97
+ else:
98
+ console.print(f"\n[green]✓ Installed {summary} from {len(sources)} package(s)[/green]")
96
99
  else:
97
100
  console.print("[red]✗ Refusing to install - conflicts detected[/red]")
98
101
 
@@ -9,7 +9,7 @@ from rich.console import Console
9
9
 
10
10
  from dotx.commands.progress import execute_plans_with_progress
11
11
  from dotx.database import InstallationDB
12
- from dotx.options import is_verbose_mode
12
+ from dotx.options import is_dry_run, is_verbose_mode
13
13
  from dotx.plan import Action, Plan, extract_plan, log_extracted_plan
14
14
  from dotx.uninstall import plan_uninstall
15
15
 
@@ -67,6 +67,9 @@ def register_command(app: typer.Typer):
67
67
  len(extract_plan(plan, {Action.UNLINK}))
68
68
  for _, plan in plans
69
69
  )
70
- console.print(f"\n[green]✓ Removed {total_removed} symlink(s) from {len(sources)} package(s)[/green]")
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]")
71
74
 
72
75
  logger.info("uninstall finished")
@@ -193,8 +193,9 @@ def plan_install(source_package_root: Path, destination_root: Path) -> Plan:
193
193
  plan=plan,
194
194
  )
195
195
  logger.debug(f"Directory {relative_destination_root_path} matches always-create pattern, will CREATE")
196
- else:
196
+ elif plan[relative_root_path].action != Action.CREATE:
197
197
  # Directory doesn't exist and has no rename conflicts - we can link it
198
+ # But only if it wasn't already marked CREATE by a descendant's mark_all_ancestors
198
199
  plan[relative_root_path].action = Action.LINK
199
200
 
200
201
  # Third pass: mark children based on what we decided for this directory
@@ -183,11 +183,12 @@ def execute_plan(
183
183
  # This does duplicate the loop code, but avoids thousands of repeated is_dry_run() calls
184
184
  # in installations with many files.
185
185
  if is_dry_run():
186
+ print("[DRY RUN] Would execute the equivalent of:")
186
187
  for step in steps:
187
188
  command = build_shell_command(step)
188
189
  if command is not None:
189
190
  logger.info(command)
190
- print(command)
191
+ print(f" {command}")
191
192
  else:
192
193
  for step in steps:
193
194
  command = build_shell_command(step)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotx
3
- Version: 3.2.0
3
+ Version: 3.2.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
@@ -58,6 +58,16 @@ purpose tool. It's made for installing a link-farm to any kind of package from
58
58
  it for other purposes, but it's tuned for its goal. It does the renaming task if you want it, but if your source files
59
59
  are named simply `.bashrc` it works just as well.
60
60
 
61
+ ### How to install
62
+
63
+ It's an ordinary PyPI-supplied Python package providing CLI entry points, so you can install it with `pip` or any other tool you like. I think the best way to install it is this:
64
+
65
+ ```bash
66
+ uv tool install dotx
67
+ ```
68
+
69
+ The current version is: v3.2.0
70
+
61
71
  ### The user interface
62
72
  ```
63
73
  Usage: dotx [OPTIONS] COMMAND [ARGS]...
@@ -246,3 +246,54 @@ def test_options_functions():
246
246
  result = runner.invoke(app, ["--verbose", "--debug", "--dry-run", "debug"])
247
247
 
248
248
  assert len(result.output) == 0
249
+
250
+
251
+ def test_cli_install_dry_run_shows_indicator(tmp_path):
252
+ """Test install with --dry-run clearly indicates dry-run mode in output."""
253
+ source = tmp_path / "source"
254
+ source.mkdir()
255
+ target = tmp_path / "target"
256
+ target.mkdir()
257
+
258
+ # Create source file
259
+ (source / "file1").write_text("content1")
260
+
261
+ runner = CliRunner()
262
+
263
+ # Install with --dry-run
264
+ result = runner.invoke(app, [f"--target={target}", "--dry-run", "install", str(source)])
265
+
266
+ assert result.exit_code == 0
267
+ # Should clearly indicate dry-run mode
268
+ assert "[DRY RUN]" in result.output
269
+ assert "Would install" in result.output
270
+ # File should NOT actually be created
271
+ assert not (target / "file1").exists()
272
+
273
+
274
+ def test_cli_uninstall_dry_run_shows_indicator(tmp_path, isolated_db):
275
+ """Test uninstall with --dry-run clearly indicates dry-run mode in output."""
276
+ source = tmp_path / "source"
277
+ source.mkdir()
278
+ target = tmp_path / "target"
279
+ target.mkdir()
280
+
281
+ # Create and install source file
282
+ (source / "file1").write_text("content1")
283
+
284
+ runner = CliRunner()
285
+
286
+ # First install for real
287
+ result = runner.invoke(app, [f"--target={target}", "install", str(source)])
288
+ assert result.exit_code == 0
289
+ assert (target / "file1").exists()
290
+
291
+ # Uninstall with --dry-run
292
+ result = runner.invoke(app, [f"--target={target}", "--dry-run", "uninstall", str(source)])
293
+
294
+ assert result.exit_code == 0
295
+ # Should clearly indicate dry-run mode
296
+ assert "[DRY RUN]" in result.output
297
+ assert "Would remove" in result.output
298
+ # File should still exist after dry-run
299
+ assert (target / "file1").exists()
@@ -543,3 +543,86 @@ def test_install_renamed_dir_inside_renamed_dir(tmp_path):
543
543
  Action.LINK, True, dir2_path, Path(".SIMPLE-DIR1/.SIMPLE-DIR2"), True
544
544
  )
545
545
  assert plan[file_path].action == Action.SKIP
546
+
547
+
548
+ def test_install_deep_nesting_with_renamed_leaf(tmp_path):
549
+ """
550
+ Test that deeply nested directories with a renamed file at the leaf install correctly.
551
+
552
+ This tests the fix for a bug where:
553
+ - Deep directory structure: a/b/c/d/e/f/dot-deepfile
554
+ - The leaf file needs renaming (dot-deepfile -> .deepfile)
555
+ - Directory f gets marked CREATE (because child needs renaming)
556
+ - All parent directories should also be CREATE (via mark_all_ancestors)
557
+
558
+ Bug was: parent dirs were overwritten to LINK when processed bottom-up,
559
+ causing FileExistsError when we tried to CREATE f under a symlinked parent.
560
+
561
+ Fix: preserve CREATE if already set by a descendant's mark_all_ancestors.
562
+ """
563
+ source_package_root = tmp_path / "source"
564
+ destination_root = tmp_path / "dest"
565
+ source_package_root.mkdir()
566
+ destination_root.mkdir()
567
+
568
+ # Create deep nesting: a/b/c/d/e/f/dot-deepfile
569
+ deep_path = Path("a/b/c/d/e/f")
570
+ file_path = deep_path / "dot-deepfile"
571
+ (source_package_root / deep_path).mkdir(parents=True)
572
+ (source_package_root / file_path).write_text("deep content")
573
+
574
+ plan = plan_install(source_package_root, destination_root)
575
+
576
+ # All directories should be CREATE (to support the renamed file at the leaf)
577
+ for part_count in range(1, 7): # a, b, c, d, e, f
578
+ parts = ["a", "b", "c", "d", "e", "f"][:part_count]
579
+ subdir_path = Path("/".join(parts))
580
+ assert plan[subdir_path].action == Action.CREATE, f"{subdir_path} should be CREATE"
581
+
582
+ # The file should be LINK with renaming
583
+ assert plan[file_path].action == Action.LINK
584
+ assert plan[file_path].requires_rename
585
+ assert plan[file_path].relative_destination_path == Path("a/b/c/d/e/f/.deepfile")
586
+
587
+
588
+ def test_install_deep_nesting_execute(tmp_path, isolated_db):
589
+ """
590
+ Test that deeply nested directories with renamed leaf actually install without error.
591
+
592
+ This is an integration test that verifies the full install flow works,
593
+ not just the planning phase.
594
+ """
595
+ from typer.testing import CliRunner
596
+
597
+ from dotx.cli import app
598
+
599
+ source_package_root = tmp_path / "source"
600
+ destination_root = tmp_path / "dest"
601
+ source_package_root.mkdir()
602
+ destination_root.mkdir()
603
+
604
+ # Create deep nesting: a/b/c/d/e/f/dot-deepfile
605
+ deep_path = Path("a/b/c/d/e/f")
606
+ file_path = deep_path / "dot-deepfile"
607
+ (source_package_root / deep_path).mkdir(parents=True)
608
+ (source_package_root / file_path).write_text("deep content")
609
+
610
+ runner = CliRunner()
611
+
612
+ # Execute install via CLI - should not raise an error
613
+ result = runner.invoke(app, [f"--target={destination_root}", "install", str(source_package_root)])
614
+
615
+ assert result.exit_code == 0, f"Install failed: {result.output}"
616
+
617
+ # Verify: all directories should be real directories (CREATE), not symlinks
618
+ for part_count in range(1, 7):
619
+ parts = ["a", "b", "c", "d", "e", "f"][:part_count]
620
+ dir_path = destination_root / "/".join(parts)
621
+ assert dir_path.is_dir(), f"{dir_path} should be a directory"
622
+ assert not dir_path.is_symlink(), f"{dir_path} should NOT be a symlink"
623
+
624
+ # The file should be a symlink with the renamed name (.deepfile, not dot-deepfile)
625
+ deep_file_dest = destination_root / "a/b/c/d/e/f/.deepfile"
626
+ assert deep_file_dest.is_symlink()
627
+ assert deep_file_dest.exists()
628
+ assert deep_file_dest.read_text() == "deep content"
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