commandertui 1.4.1__tar.gz → 1.4.3__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 (48) hide show
  1. {commandertui-1.4.1 → commandertui-1.4.3}/PKG-INFO +5 -3
  2. {commandertui-1.4.1 → commandertui-1.4.3}/README.md +4 -2
  3. commandertui-1.4.3/commandertui/__init__.py +1 -0
  4. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui/main_screen.py +84 -16
  5. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui/modals.py +15 -0
  6. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui/netlocations.py +67 -13
  7. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui/updater.py +22 -4
  8. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui.egg-info/PKG-INFO +5 -3
  9. {commandertui-1.4.1 → commandertui-1.4.3}/tests/test_app_smoke.py +118 -0
  10. {commandertui-1.4.1 → commandertui-1.4.3}/tests/test_netlocations.py +65 -3
  11. {commandertui-1.4.1 → commandertui-1.4.3}/tests/test_updater.py +34 -2
  12. commandertui-1.4.1/commandertui/__init__.py +0 -1
  13. {commandertui-1.4.1 → commandertui-1.4.3}/LICENSE +0 -0
  14. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui/app.py +0 -0
  15. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui/ascii_font.py +0 -0
  16. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui/bookmarks.py +0 -0
  17. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui/boot.py +0 -0
  18. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui/cli.py +0 -0
  19. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui/compare_screen.py +0 -0
  20. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui/diff.py +0 -0
  21. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui/editor.py +0 -0
  22. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui/executor.py +0 -0
  23. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui/models.py +0 -0
  24. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui/plan.py +0 -0
  25. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui/preview.py +0 -0
  26. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui/progress_screen.py +0 -0
  27. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui/run_queue.py +0 -0
  28. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui/scanner.py +0 -0
  29. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui/settings.py +0 -0
  30. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui/sync.py +0 -0
  31. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui/theme.py +0 -0
  32. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui/trash.py +0 -0
  33. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui/widgets.py +0 -0
  34. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui.egg-info/SOURCES.txt +0 -0
  35. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui.egg-info/dependency_links.txt +0 -0
  36. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui.egg-info/entry_points.txt +0 -0
  37. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui.egg-info/requires.txt +0 -0
  38. {commandertui-1.4.1 → commandertui-1.4.3}/commandertui.egg-info/top_level.txt +0 -0
  39. {commandertui-1.4.1 → commandertui-1.4.3}/pyproject.toml +0 -0
  40. {commandertui-1.4.1 → commandertui-1.4.3}/setup.cfg +0 -0
  41. {commandertui-1.4.1 → commandertui-1.4.3}/tests/test_bookmarks.py +0 -0
  42. {commandertui-1.4.1 → commandertui-1.4.3}/tests/test_diff.py +0 -0
  43. {commandertui-1.4.1 → commandertui-1.4.3}/tests/test_executor.py +0 -0
  44. {commandertui-1.4.1 → commandertui-1.4.3}/tests/test_plan.py +0 -0
  45. {commandertui-1.4.1 → commandertui-1.4.3}/tests/test_preview.py +0 -0
  46. {commandertui-1.4.1 → commandertui-1.4.3}/tests/test_scanner.py +0 -0
  47. {commandertui-1.4.1 → commandertui-1.4.3}/tests/test_sync.py +0 -0
  48. {commandertui-1.4.1 → commandertui-1.4.3}/tests/test_widgets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commandertui
3
- Version: 1.4.1
3
+ Version: 1.4.3
4
4
  Summary: Dual-pane retro terminal file manager: compare, sync, copy and move folders
5
5
  Author: Humberto Barchini
6
6
  License-Expression: MIT
@@ -140,7 +140,7 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
140
140
  | `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
141
141
  | `h` | Show/hide hidden (dot) files in both panes |
142
142
  | `t` | Color theme: Phosphor green (default), Phosphor amber, Nord, Gruvbox, Dracula, Tokyo Night, Catppuccin Mocha, Solarized Dark, Monokai, Textual Dark. Previews as you move, Enter keeps it (saved in `~/.config/commandertui/settings.json`), Esc goes back |
143
- | `p` | Places: jump to a mounted drive (USB/SMB/rclone), saved bookmark or saved network location. Inside: `a` add a network location, `s` save the active pane's folder as a bookmark, `d` delete a saved one, `e` unmount a mounted SMB share |
143
+ | `p` | Places: jump to a mounted drive (USB/SMB/rclone), saved bookmark or saved network location. Inside: `a` add a network location, `s` save the active pane's folder as a bookmark, `d` delete a saved one, `e` unmount a mounted SMB share, `l` change the login of a saved network location (forgets the session, then anonymous or another user) |
144
144
  | `c` | Compare: full recursive diff between the two panes' current folders, with size and date of both sides (and which is newer when a file changed) |
145
145
  | `r` | Refresh both panes |
146
146
  | `q` | Quit |
@@ -162,7 +162,9 @@ location is saved in `~/.config/commandertui/network.json` (protocol, host,
162
162
  share and user only -- never a password) and reappears in Places, mounting itself
163
163
  when picked. If the server asks for a login, `gio` asks for it in the
164
164
  terminal and can remember it in the system keyring, exactly like Nautilus (on macOS, the system login dialog asks and saves it in Keychain).
165
- If the GVFS backend for the chosen protocol is not installed (a bare Ubuntu server, for example, has none), the app says so and offers to install it for you (`apt-get install gvfs-backends`, `pacman -S gvfs gvfs-smb` or `dnf install gvfs-smb`); it asks for your sudo password in the terminal and only runs if you answer *Install now*.
165
+ For SMB and FTP the app first asks **Anonymous (no login)** or **User and password**; anonymous connects with `gio mount --anonymous`, with no user, workgroup or password prompt, and is remembered per location. Choosing *User and password* forgets any earlier session for that server first, so the login prompt always appears.
166
+
167
+ If the GVFS backend (or `gvfs-fuse`, which gives a mounted share a folder to browse) for the chosen protocol is not installed (a bare Ubuntu server, for example, has none), the app says so and offers to install it for you (`apt-get install gvfs-backends gvfs-fuse`, `pacman -S gvfs gvfs-smb` or `dnf install gvfs-smb gvfs-fuse`); it asks for your sudo password in the terminal and only runs if you answer *Install now*.
166
168
 
167
169
  Every key also has a clickable box on the bottom bar: one row on wide windows
168
170
  (168+ columns), two rows when narrower (120 columns is the practical minimum).
@@ -121,7 +121,7 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
121
121
  | `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
122
122
  | `h` | Show/hide hidden (dot) files in both panes |
123
123
  | `t` | Color theme: Phosphor green (default), Phosphor amber, Nord, Gruvbox, Dracula, Tokyo Night, Catppuccin Mocha, Solarized Dark, Monokai, Textual Dark. Previews as you move, Enter keeps it (saved in `~/.config/commandertui/settings.json`), Esc goes back |
124
- | `p` | Places: jump to a mounted drive (USB/SMB/rclone), saved bookmark or saved network location. Inside: `a` add a network location, `s` save the active pane's folder as a bookmark, `d` delete a saved one, `e` unmount a mounted SMB share |
124
+ | `p` | Places: jump to a mounted drive (USB/SMB/rclone), saved bookmark or saved network location. Inside: `a` add a network location, `s` save the active pane's folder as a bookmark, `d` delete a saved one, `e` unmount a mounted SMB share, `l` change the login of a saved network location (forgets the session, then anonymous or another user) |
125
125
  | `c` | Compare: full recursive diff between the two panes' current folders, with size and date of both sides (and which is newer when a file changed) |
126
126
  | `r` | Refresh both panes |
127
127
  | `q` | Quit |
@@ -143,7 +143,9 @@ location is saved in `~/.config/commandertui/network.json` (protocol, host,
143
143
  share and user only -- never a password) and reappears in Places, mounting itself
144
144
  when picked. If the server asks for a login, `gio` asks for it in the
145
145
  terminal and can remember it in the system keyring, exactly like Nautilus (on macOS, the system login dialog asks and saves it in Keychain).
146
- If the GVFS backend for the chosen protocol is not installed (a bare Ubuntu server, for example, has none), the app says so and offers to install it for you (`apt-get install gvfs-backends`, `pacman -S gvfs gvfs-smb` or `dnf install gvfs-smb`); it asks for your sudo password in the terminal and only runs if you answer *Install now*.
146
+ For SMB and FTP the app first asks **Anonymous (no login)** or **User and password**; anonymous connects with `gio mount --anonymous`, with no user, workgroup or password prompt, and is remembered per location. Choosing *User and password* forgets any earlier session for that server first, so the login prompt always appears.
147
+
148
+ If the GVFS backend (or `gvfs-fuse`, which gives a mounted share a folder to browse) for the chosen protocol is not installed (a bare Ubuntu server, for example, has none), the app says so and offers to install it for you (`apt-get install gvfs-backends gvfs-fuse`, `pacman -S gvfs gvfs-smb` or `dnf install gvfs-smb gvfs-fuse`); it asks for your sudo password in the terminal and only runs if you answer *Install now*.
147
149
 
148
150
  Every key also has a clickable box on the bottom bar: one row on wide windows
149
151
  (168+ columns), two rows when narrower (120 columns is the practical minimum).
@@ -0,0 +1 @@
1
+ __version__ = "1.4.3"
@@ -13,6 +13,7 @@ from .compare_screen import CompareScreen
13
13
  from . import __version__, editor, netlocations, preview, theme, updater
14
14
  from .modals import (
15
15
  ADD_NETWORK,
16
+ CHANGE_LOGIN,
16
17
  ChoiceScreen,
17
18
  FilterScreen,
18
19
  InputScreen,
@@ -26,6 +27,9 @@ from .run_queue import run_queue_with_progress
26
27
  from .scanner import path_size
27
28
  from .widgets import PARENT_MARK, FilePanel, FunctionBar, MainTopBar
28
29
 
30
+ LOGIN_ANON = "Anonymous (no login)"
31
+ LOGIN_USER = "User and password"
32
+
29
33
 
30
34
  class MainScreen(Screen):
31
35
  """The dual-pane view and its keybindings.
@@ -346,7 +350,9 @@ class MainScreen(Screen):
346
350
  )
347
351
  else:
348
352
  self.app.push_screen(
349
- MessageScreen("The update did not finish. Check the terminal output, then try again.")
353
+ MessageScreen(
354
+ "The update did not finish. The package index may not have the new version yet:\nwait a few minutes and try again, or check the terminal output."
355
+ )
350
356
  )
351
357
 
352
358
  self.app.push_screen(UpdateScreen(info), on_choice)
@@ -519,6 +525,8 @@ class MainScreen(Screen):
519
525
  return
520
526
  if path == ADD_NETWORK:
521
527
  self._add_network()
528
+ elif path.startswith(CHANGE_LOGIN):
529
+ self._change_login(path[len(CHANGE_LOGIN) :])
522
530
  elif netlocations.is_uri(path):
523
531
  self._connect(path)
524
532
  else:
@@ -547,14 +555,27 @@ class MainScreen(Screen):
547
555
  if not host or not host.strip():
548
556
  return
549
557
  host_clean = host.strip()
550
- if scheme == "smb":
551
- if netlocations.is_macos():
552
- self.app.push_screen(InputScreen("SMB user:"), lambda u: on_user(host_clean, u))
553
- else:
554
- self._pick_share(host_clean)
558
+ if scheme == "smb" and netlocations.is_macos():
559
+ self.app.push_screen(InputScreen("SMB user:"), lambda u: on_user(host_clean, u))
560
+ elif scheme in ("smb", "ftp"):
561
+ self.app.push_screen(
562
+ ChoiceScreen(f"Login for {host_clean}", [LOGIN_ANON, LOGIN_USER]),
563
+ lambda choice: on_login(scheme, host_clean, choice),
564
+ )
555
565
  else:
556
566
  self._connect(netlocations.build_uri(scheme, host_clean), save=True)
557
567
 
568
+ def on_login(scheme: str, host: str, choice: str | None) -> None:
569
+ if not choice:
570
+ return
571
+ anonymous = choice == LOGIN_ANON
572
+ if scheme == "smb":
573
+ self._pick_share(host, anonymous=anonymous, reset=not anonymous)
574
+ else:
575
+ self._connect(
576
+ netlocations.build_uri(scheme, host), save=True, anonymous=anonymous, reset=not anonymous
577
+ )
578
+
558
579
  def on_scheme(choice: str | None) -> None:
559
580
  if not choice:
560
581
  return
@@ -571,6 +592,32 @@ class MainScreen(Screen):
571
592
 
572
593
  self.app.push_screen(ChoiceScreen("Protocol", ["SMB", "SFTP", "FTP"]), on_scheme)
573
594
 
595
+ def _change_login(self, uri: str) -> None:
596
+ """Forget the session of a saved location and connect again, anonymous or with another user."""
597
+ if netlocations.is_macos():
598
+ self.app.push_screen(
599
+ MessageScreen("Change login is not available on macOS: remove the location and add it again.")
600
+ )
601
+ return
602
+ local = netlocations.local_path(uri)
603
+ if local:
604
+ for side in ("left", "right"):
605
+ panel = self._panel(side)
606
+ if panel.path == local or panel.path.startswith(local.rstrip(os.sep) + os.sep):
607
+ panel.path = os.path.expanduser("~")
608
+ panel.marked.clear()
609
+ panel.refresh_listing()
610
+
611
+ def on_choice(choice: str | None) -> None:
612
+ if not choice:
613
+ return
614
+ anonymous = choice == LOGIN_ANON
615
+ self._connect(uri, save=True, anonymous=anonymous, reset=True)
616
+
617
+ scheme = uri.partition("://")[0].lower()
618
+ options = [LOGIN_ANON, LOGIN_USER] if scheme in ("smb", "ftp") else [LOGIN_USER]
619
+ self.app.push_screen(ChoiceScreen(f"Login for {netlocations.label_for(uri)}", options), on_choice)
620
+
574
621
  def _offer_backend_install(self, scheme: str, label: str) -> None:
575
622
  command = netlocations.backend_install_command()
576
623
  if command is None:
@@ -606,16 +653,25 @@ class MainScreen(Screen):
606
653
  on_choice,
607
654
  )
608
655
 
609
- def _pick_share(self, host: str, retried: bool = False, user: str | None = None) -> None:
656
+ def _pick_share(
657
+ self,
658
+ host: str,
659
+ retried: bool = False,
660
+ user: str | None = None,
661
+ anonymous: bool = False,
662
+ reset: bool = False,
663
+ ) -> None:
610
664
  self.notify(f"Listing shares on {host}...")
611
665
 
612
666
  def work() -> None:
613
- shares, err = netlocations.list_shares(host, user=user)
667
+ if reset: # forget a previous session so the server asks for a login again
668
+ netlocations.reset_login(netlocations.build_uri("smb", host))
669
+ shares, err = netlocations.list_shares(host, user=user, anonymous=anonymous)
614
670
  self.app.call_from_thread(done, shares, err)
615
671
 
616
672
  def done(shares: list[str] | None, err: str) -> None:
617
673
  if shares is None:
618
- if retried:
674
+ if retried or anonymous:
619
675
  self.app.push_screen(MessageScreen(f"Could not list shares on {host}.\n{err}"))
620
676
  return
621
677
  if netlocations.is_macos():
@@ -633,33 +689,45 @@ class MainScreen(Screen):
633
689
  self.app.push_screen(
634
690
  ChoiceScreen(f"Shares on {host}", shares),
635
691
  lambda share: share
636
- and self._connect(netlocations.build_uri("smb", host, share, user=user), save=True),
692
+ and self._connect(
693
+ netlocations.build_uri("smb", host, share, user=user), save=True, anonymous=anonymous
694
+ ),
637
695
  )
638
696
 
639
697
  self.run_worker(work, thread=True)
640
698
 
641
- def _connect(self, uri: str, save: bool = False, retried: bool = False) -> None:
699
+ def _connect(
700
+ self,
701
+ uri: str,
702
+ save: bool = False,
703
+ retried: bool = False,
704
+ anonymous: bool | None = None,
705
+ reset: bool = False,
706
+ ) -> None:
642
707
  if netlocations.is_macos() and not uri.lower().startswith("smb://"):
643
708
  self.app.push_screen(MessageScreen(netlocations.MACOS_UNSUPPORTED_MSG))
644
709
  return
645
710
  self.notify(f"Connecting to {uri} ...")
711
+ anon = netlocations.is_anonymous(uri) if anonymous is None else anonymous
646
712
 
647
713
  def work() -> None:
648
- ok, err = netlocations.mount(uri)
714
+ if reset: # forget a previous session so the server asks for a login again
715
+ netlocations.reset_login(uri)
716
+ ok, err = netlocations.mount(uri, anonymous=anon)
649
717
  path = netlocations.local_path(uri) if ok else None
650
718
  self.app.call_from_thread(done, path, err)
651
719
 
652
720
  def done(path: str | None, err: str) -> None:
653
721
  if path:
654
722
  if save:
655
- netlocations.save_location(uri)
723
+ netlocations.save_location(uri, anonymous=anon)
656
724
  self._go_to(path)
657
725
  return
658
- if retried or netlocations.is_macos():
726
+ if retried or anon or netlocations.is_macos():
659
727
  self.app.push_screen(MessageScreen(f"Could not connect to {uri}.\n{err}"))
660
728
  return
661
729
  with self.app.suspend():
662
- netlocations.mount(uri, interactive=True)
663
- self._connect(uri, save=save, retried=True)
730
+ netlocations.mount(uri, interactive=True, anonymous=False)
731
+ self._connect(uri, save=save, retried=True, anonymous=False)
664
732
 
665
733
  self.run_worker(work, thread=True)
@@ -117,6 +117,7 @@ class ConfirmScreen(ModalScreen[bool]):
117
117
  self.dismiss(False)
118
118
 
119
119
 
120
+ CHANGE_LOGIN = "\0change-login:" # prefix PlacesScreen dismisses with, followed by the saved location's URI
120
121
  ADD_NETWORK = "\0add-network" # sentinel PlacesScreen dismisses with to start the add-network flow
121
122
 
122
123
 
@@ -160,6 +161,7 @@ class PlacesScreen(ModalScreen[str | None]):
160
161
  Binding("s", "save_here", "Save here", priority=True),
161
162
  Binding("d", "delete_saved", "Delete saved", priority=True),
162
163
  Binding("e", "unmount_share", "Unmount share", priority=True),
164
+ Binding("l", "change_login", "Change login", priority=True),
163
165
  Binding("escape", "cancel", "Cancel", priority=True),
164
166
  ]
165
167
 
@@ -178,6 +180,7 @@ class PlacesScreen(ModalScreen[str | None]):
178
180
  yield Button("Save here (s)", id="save")
179
181
  yield Button("Delete saved (d)", id="delete")
180
182
  yield Button("Unmount share (e)", id="unmount")
183
+ yield Button("Change login (l)", id="login")
181
184
  yield Button("Close (Esc)", id="close")
182
185
 
183
186
  def on_mount(self) -> None:
@@ -238,6 +241,17 @@ class PlacesScreen(ModalScreen[str | None]):
238
241
  panel.marked.clear()
239
242
  panel.refresh_listing()
240
243
 
244
+ def action_change_login(self) -> None:
245
+ """Forget the session of a saved network location to log in again (anonymous or another user)."""
246
+ table = self.query_one("#places-table", DataTable)
247
+ if not self.places or table.cursor_row >= len(self.places):
248
+ return
249
+ place = self.places[table.cursor_row]
250
+ if place.kind != "net-saved":
251
+ self.notify("Pick a saved network location to change its login.", severity="warning")
252
+ return
253
+ self.dismiss(CHANGE_LOGIN + place.path)
254
+
241
255
  def action_unmount_share(self) -> None:
242
256
  table = self.query_one("#places-table", DataTable)
243
257
  if not self.places or table.cursor_row >= len(self.places):
@@ -268,6 +282,7 @@ class PlacesScreen(ModalScreen[str | None]):
268
282
  "save": self.action_save_here,
269
283
  "delete": self.action_delete_saved,
270
284
  "unmount": self.action_unmount_share,
285
+ "login": self.action_change_login,
271
286
  }.get(event.button.id, self.action_cancel)()
272
287
 
273
288
  def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
@@ -32,6 +32,7 @@ class NetLocation:
32
32
  label: str
33
33
  uri: str
34
34
  user: str | None = None
35
+ anonymous: bool = False
35
36
 
36
37
 
37
38
  def is_macos() -> bool:
@@ -57,19 +58,36 @@ def _gvfs_mount_dirs() -> list[str]:
57
58
  ]
58
59
 
59
60
 
61
+ _FUSE_DAEMONS = (
62
+ "/usr/libexec/gvfsd-fuse",
63
+ "/usr/lib/gvfsd-fuse",
64
+ "/usr/lib/gvfs/gvfsd-fuse",
65
+ "/usr/lib64/gvfs/gvfsd-fuse",
66
+ "/usr/lib/x86_64-linux-gnu/gvfs/gvfsd-fuse",
67
+ "/usr/lib/aarch64-linux-gnu/gvfs/gvfsd-fuse",
68
+ )
69
+
70
+
71
+ def _fuse_present() -> bool:
72
+ """GVFS shows a mounted share as a local folder (~/.../gvfs/...) only through gvfsd-fuse."""
73
+ return any(os.path.isfile(p) for p in _FUSE_DAEMONS) or shutil.which("gvfsd-fuse") is not None
74
+
75
+
60
76
  def backend_missing(scheme: str) -> bool:
61
- """True on Linux when GVFS has no backend for `scheme` (gio then answers
62
- "Operation not supported"). Always False on macOS and when `gio` is absent."""
77
+ """True on Linux when GVFS cannot serve `scheme`: no backend for it (gio then answers
78
+ "Operation not supported") or no gvfsd-fuse (the share mounts but has no local folder to open).
79
+ Always False on macOS and when `gio` is absent."""
63
80
  if is_macos() or not gio_available():
64
81
  return False
65
- return not any(os.path.isfile(os.path.join(d, f"{scheme}.mount")) for d in _gvfs_mount_dirs())
82
+ has_backend = any(os.path.isfile(os.path.join(d, f"{scheme}.mount")) for d in _gvfs_mount_dirs())
83
+ return not (has_backend and _fuse_present())
66
84
 
67
85
 
68
86
  # Package that ships the GVFS backends, per package manager.
69
87
  _BACKEND_INSTALL = {
70
- "apt-get": ["sudo", "apt-get", "install", "gvfs-backends"],
88
+ "apt-get": ["sudo", "apt-get", "install", "gvfs-backends", "gvfs-fuse"],
71
89
  "pacman": ["sudo", "pacman", "-S", "gvfs", "gvfs-smb"],
72
- "dnf": ["sudo", "dnf", "install", "gvfs-smb"],
90
+ "dnf": ["sudo", "dnf", "install", "gvfs-smb", "gvfs-fuse"],
73
91
  }
74
92
 
75
93
 
@@ -86,7 +104,11 @@ def install_backend() -> bool:
86
104
  command = backend_install_command()
87
105
  if command is None:
88
106
  return False
89
- return subprocess.run(command).returncode == 0
107
+ if subprocess.run(command).returncode != 0:
108
+ return False
109
+ # A gvfsd that was already running does not know the new components: let it restart on demand.
110
+ subprocess.run(["pkill", "-x", "gvfsd"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
111
+ return True
90
112
 
91
113
 
92
114
  def build_uri(scheme: str, host: str, share: str | None = None, user: str | None = None) -> str:
@@ -187,11 +209,15 @@ def _list_shares_darwin(host: str, user: str | None = None) -> tuple[list[str] |
187
209
  return _parse_smbutil_view(res.stdout), ""
188
210
 
189
211
 
190
- def list_shares(host: str, user: str | None = None) -> tuple[list[str] | None, str]:
212
+ def list_shares(host: str, user: str | None = None, anonymous: bool = False) -> tuple[list[str] | None, str]:
191
213
  """Shares on an SMB server. Returns (names, "") or (None, error) -- the
192
214
  caller retries after an interactive mount if login was the problem."""
193
215
  if is_macos():
194
216
  return _list_shares_darwin(host, user=user)
217
+ if anonymous:
218
+ ok, err = mount(build_uri("smb", host), anonymous=True)
219
+ if not ok:
220
+ return None, err
195
221
  try:
196
222
  res = _gio(["list", build_uri("smb", host)])
197
223
  except subprocess.TimeoutExpired:
@@ -291,11 +317,17 @@ def _mount_darwin(uri: str, interactive: bool = False) -> tuple[bool, str]:
291
317
  return False, f"timed out waiting for /Volumes/{share}"
292
318
 
293
319
 
294
- def mount(uri: str, interactive: bool = False) -> tuple[bool, str]:
320
+ def mount(uri: str, interactive: bool = False, anonymous: bool | None = None) -> tuple[bool, str]:
321
+ """`anonymous=None` means: anonymous if the saved location says so. An anonymous
322
+ mount never prompts (`gio mount --anonymous`), so it is never interactive."""
295
323
  if is_macos():
296
324
  return _mount_darwin(uri, interactive=interactive)
325
+ if anonymous is None:
326
+ anonymous = is_anonymous(uri)
327
+ if anonymous:
328
+ interactive = False
297
329
  try:
298
- res = _gio(["mount", uri], interactive)
330
+ res = _gio(["mount", "--anonymous", uri] if anonymous else ["mount", uri], interactive)
299
331
  except subprocess.TimeoutExpired:
300
332
  return False, "timed out"
301
333
  if res.returncode == 0:
@@ -397,7 +429,9 @@ def load_locations() -> list[NetLocation]:
397
429
  for d in json.load(f):
398
430
  uri = d["uri"]
399
431
  user = d.get("user") or _split_uri(uri)[1] or None
400
- items.append(NetLocation(label=d["label"], uri=uri, user=user))
432
+ items.append(
433
+ NetLocation(label=d["label"], uri=uri, user=user, anonymous=bool(d.get("anonymous")))
434
+ )
401
435
  return items
402
436
  except (OSError, ValueError, KeyError):
403
437
  return []
@@ -412,18 +446,38 @@ def _write(locations: list[NetLocation]) -> None:
412
446
  user = loc.user or uri_user
413
447
  if user:
414
448
  row["user"] = user
449
+ if loc.anonymous:
450
+ row["anonymous"] = True
415
451
  rows.append(row)
416
452
  with open(NETWORK_FILE, "w") as f:
417
453
  json.dump(rows, f, indent=2)
418
454
 
419
455
 
420
- def save_location(uri: str, user: str | None = None) -> None:
456
+ def save_location(uri: str, user: str | None = None, anonymous: bool = False) -> None:
421
457
  _, uri_user, _, _ = _split_uri(uri)
422
- effective_user = user or uri_user or None
458
+ effective_user = None if anonymous else (user or uri_user or None)
423
459
  locations = [loc for loc in load_locations() if loc.uri != uri]
424
- locations.append(NetLocation(label=label_for(uri), uri=uri, user=effective_user))
460
+ locations.append(NetLocation(label=label_for(uri), uri=uri, user=effective_user, anonymous=anonymous))
425
461
  _write(locations)
426
462
 
427
463
 
464
+ def is_anonymous(uri: str) -> bool:
465
+ """True when `uri` is a saved location that connects without a login."""
466
+ return any(loc.anonymous for loc in load_locations() if loc.uri == uri)
467
+
468
+
428
469
  def remove_location(uri: str) -> None:
429
470
  _write([loc for loc in load_locations() if loc.uri != uri])
471
+
472
+
473
+ def reset_login(uri: str) -> None:
474
+ """Make gio forget the session for this location so the next connect asks for a login again:
475
+ unmounts the share and the server's browse mount. Not being mounted is fine; macOS has no such session."""
476
+ if is_macos():
477
+ return
478
+ scheme, _, host, _ = _split_uri(uri)
479
+ for target in dict.fromkeys([uri, build_uri(scheme, host)]):
480
+ try:
481
+ _gio(["mount", "-u", target])
482
+ except subprocess.TimeoutExpired:
483
+ pass
@@ -112,10 +112,12 @@ makepkg -si
112
112
 
113
113
  def update_command(install: str, latest: str) -> list[str] | None:
114
114
  """The command to run for `install`, or None when there isn't a safe one."""
115
+ # --no-cache-dir: pip keeps the package index for minutes, so right after a release it
116
+ # can still say "already up to date" for a version PyPI already serves.
115
117
  if install == "pipx":
116
- return ["pipx", "upgrade", PACKAGE]
118
+ return ["pipx", "upgrade", "--pip-args=--no-cache-dir", PACKAGE]
117
119
  if install == "pip":
118
- return [sys.executable, "-m", "pip", "install", "--upgrade", PACKAGE]
120
+ return [sys.executable, "-m", "pip", "install", "--upgrade", "--no-cache-dir", PACKAGE]
119
121
  if install == "pacman":
120
122
  return ["bash", "-c", _ARCH_SCRIPT.format(repo=REPO, latest=latest)]
121
123
  return None # git checkout: the user updates it with git
@@ -174,9 +176,25 @@ def skip_version(version: str) -> None:
174
176
  _save_cache(cache)
175
177
 
176
178
 
179
+ def installed_version() -> str | None:
180
+ """The version on disk right now (not the one this process started with), or None if unknown."""
181
+ try:
182
+ from importlib import metadata
183
+
184
+ metadata.MetadataPathFinder.invalidate_caches()
185
+ return metadata.version(PACKAGE)
186
+ except Exception:
187
+ return None
188
+
189
+
177
190
  def run_update(info: UpdateInfo) -> bool:
178
- """Run the upgrade attached to the real terminal. True if it exited 0."""
191
+ """Run the upgrade attached to the real terminal. True only if it exited 0 AND the
192
+ new version is really installed: a package manager can exit 0 with "already up to date"
193
+ when its index has not caught up with the release yet."""
179
194
  command = update_command(info.install, info.latest)
180
195
  if command is None:
181
196
  return False
182
- return subprocess.run(command).returncode == 0
197
+ if subprocess.run(command).returncode != 0:
198
+ return False
199
+ now = installed_version()
200
+ return now is None or now == info.latest # unknown version: trust the exit code
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commandertui
3
- Version: 1.4.1
3
+ Version: 1.4.3
4
4
  Summary: Dual-pane retro terminal file manager: compare, sync, copy and move folders
5
5
  Author: Humberto Barchini
6
6
  License-Expression: MIT
@@ -140,7 +140,7 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
140
140
  | `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
141
141
  | `h` | Show/hide hidden (dot) files in both panes |
142
142
  | `t` | Color theme: Phosphor green (default), Phosphor amber, Nord, Gruvbox, Dracula, Tokyo Night, Catppuccin Mocha, Solarized Dark, Monokai, Textual Dark. Previews as you move, Enter keeps it (saved in `~/.config/commandertui/settings.json`), Esc goes back |
143
- | `p` | Places: jump to a mounted drive (USB/SMB/rclone), saved bookmark or saved network location. Inside: `a` add a network location, `s` save the active pane's folder as a bookmark, `d` delete a saved one, `e` unmount a mounted SMB share |
143
+ | `p` | Places: jump to a mounted drive (USB/SMB/rclone), saved bookmark or saved network location. Inside: `a` add a network location, `s` save the active pane's folder as a bookmark, `d` delete a saved one, `e` unmount a mounted SMB share, `l` change the login of a saved network location (forgets the session, then anonymous or another user) |
144
144
  | `c` | Compare: full recursive diff between the two panes' current folders, with size and date of both sides (and which is newer when a file changed) |
145
145
  | `r` | Refresh both panes |
146
146
  | `q` | Quit |
@@ -162,7 +162,9 @@ location is saved in `~/.config/commandertui/network.json` (protocol, host,
162
162
  share and user only -- never a password) and reappears in Places, mounting itself
163
163
  when picked. If the server asks for a login, `gio` asks for it in the
164
164
  terminal and can remember it in the system keyring, exactly like Nautilus (on macOS, the system login dialog asks and saves it in Keychain).
165
- If the GVFS backend for the chosen protocol is not installed (a bare Ubuntu server, for example, has none), the app says so and offers to install it for you (`apt-get install gvfs-backends`, `pacman -S gvfs gvfs-smb` or `dnf install gvfs-smb`); it asks for your sudo password in the terminal and only runs if you answer *Install now*.
165
+ For SMB and FTP the app first asks **Anonymous (no login)** or **User and password**; anonymous connects with `gio mount --anonymous`, with no user, workgroup or password prompt, and is remembered per location. Choosing *User and password* forgets any earlier session for that server first, so the login prompt always appears.
166
+
167
+ If the GVFS backend (or `gvfs-fuse`, which gives a mounted share a folder to browse) for the chosen protocol is not installed (a bare Ubuntu server, for example, has none), the app says so and offers to install it for you (`apt-get install gvfs-backends gvfs-fuse`, `pacman -S gvfs gvfs-smb` or `dnf install gvfs-smb gvfs-fuse`); it asks for your sudo password in the terminal and only runs if you answer *Install now*.
166
168
 
167
169
  Every key also has a clickable box on the bottom bar: one row on wide windows
168
170
  (168+ columns), two rows when narrower (120 columns is the practical minimum).
@@ -1222,3 +1222,121 @@ async def test_missing_gvfs_backend_cancel_installs_nothing(tmp_path, monkeypatc
1222
1222
  await pilot.press("down", "enter") # "Cancel"
1223
1223
  await pilot.pause()
1224
1224
  assert ran == []
1225
+
1226
+
1227
+ @pytest.mark.asyncio
1228
+ async def test_add_smb_asks_login_and_anonymous_skips_every_prompt(tmp_path, monkeypatch):
1229
+ from commandertui import netlocations
1230
+ from commandertui.modals import ChoiceScreen, InputScreen
1231
+
1232
+ seen = {}
1233
+ monkeypatch.setattr(netlocations, "gio_available", lambda: True)
1234
+ monkeypatch.setattr(netlocations, "backend_missing", lambda scheme: False)
1235
+ monkeypatch.setattr(netlocations.sys, "platform", "linux")
1236
+
1237
+ def fake_list(host, user=None, anonymous=False):
1238
+ seen["list"] = (host, user, anonymous)
1239
+ return ["Public", "HD1"], ""
1240
+
1241
+ monkeypatch.setattr(netlocations, "list_shares", fake_list)
1242
+ monkeypatch.setattr(netlocations, "reset_login", lambda uri: seen.setdefault("reset", uri))
1243
+ monkeypatch.setattr(
1244
+ netlocations,
1245
+ "mount",
1246
+ lambda uri, interactive=False, anonymous=None: seen.update(mount=(uri, interactive, anonymous))
1247
+ or (True, ""),
1248
+ )
1249
+ monkeypatch.setattr(netlocations, "local_path", lambda uri: str(tmp_path))
1250
+ saved = []
1251
+ monkeypatch.setattr(
1252
+ netlocations, "save_location", lambda uri, user=None, anonymous=False: saved.append((uri, anonymous))
1253
+ )
1254
+
1255
+ app, left, right = await _open_confirm(tmp_path, "")
1256
+ async with app.run_test(size=(140, 30)) as pilot:
1257
+ await _skip_boot(pilot)
1258
+ await pilot.press("p", "a", "enter") # Places -> Add -> SMB
1259
+ await pilot.pause()
1260
+ assert isinstance(app.screen, InputScreen)
1261
+ app.screen.query_one("#input-field").value = "192.168.100.14"
1262
+ await pilot.press("enter")
1263
+ await pilot.pause()
1264
+ assert isinstance(app.screen, ChoiceScreen) and app.screen.options == [
1265
+ "Anonymous (no login)",
1266
+ "User and password",
1267
+ ]
1268
+ await pilot.press("enter") # Anonymous
1269
+ await pilot.pause()
1270
+ await pilot.pause()
1271
+ assert seen["list"] == ("192.168.100.14", None, True)
1272
+ assert "reset" not in seen # anonymous does not need a fresh session
1273
+ assert isinstance(app.screen, ChoiceScreen) and app.screen.options == ["Public", "HD1"]
1274
+ await pilot.press("enter") # Public
1275
+ await pilot.pause()
1276
+ await pilot.pause()
1277
+ assert seen["mount"] == ("smb://192.168.100.14/Public/", False, True)
1278
+ assert saved == [("smb://192.168.100.14/Public/", True)]
1279
+
1280
+
1281
+ @pytest.mark.asyncio
1282
+ async def test_add_smb_with_user_resets_the_old_session_first(tmp_path, monkeypatch):
1283
+ from commandertui import netlocations
1284
+
1285
+ seen = {}
1286
+ monkeypatch.setattr(netlocations, "gio_available", lambda: True)
1287
+ monkeypatch.setattr(netlocations, "backend_missing", lambda scheme: False)
1288
+ monkeypatch.setattr(netlocations.sys, "platform", "linux")
1289
+ monkeypatch.setattr(netlocations, "reset_login", lambda uri: seen.update(reset=uri))
1290
+ monkeypatch.setattr(
1291
+ netlocations,
1292
+ "list_shares",
1293
+ lambda host, user=None, anonymous=False: seen.update(list=anonymous) or (["HD1"], ""),
1294
+ )
1295
+
1296
+ app, left, right = await _open_confirm(tmp_path, "")
1297
+ async with app.run_test(size=(140, 30)) as pilot:
1298
+ await _skip_boot(pilot)
1299
+ await pilot.press("p", "a", "enter")
1300
+ await pilot.pause()
1301
+ app.screen.query_one("#input-field").value = "nas"
1302
+ await pilot.press("enter")
1303
+ await pilot.pause()
1304
+ await pilot.press("down", "enter") # User and password
1305
+ await pilot.pause()
1306
+ await pilot.pause()
1307
+ assert seen == {"reset": "smb://nas/", "list": False}
1308
+
1309
+
1310
+ @pytest.mark.asyncio
1311
+ async def test_places_change_login_resets_and_reconnects(tmp_path, monkeypatch):
1312
+ from commandertui import netlocations
1313
+ from commandertui.modals import ChoiceScreen
1314
+
1315
+ monkeypatch.setattr(netlocations, "NETWORK_FILE", str(tmp_path / "network.json"))
1316
+ monkeypatch.setattr(netlocations.sys, "platform", "linux")
1317
+ netlocations.save_location("smb://nas/HD1/", user="old")
1318
+ seen = {}
1319
+ monkeypatch.setattr(netlocations, "reset_login", lambda uri: seen.update(reset=uri))
1320
+ monkeypatch.setattr(
1321
+ netlocations,
1322
+ "mount",
1323
+ lambda uri, interactive=False, anonymous=None: seen.update(mount=(uri, anonymous)) or (True, ""),
1324
+ )
1325
+ monkeypatch.setattr(netlocations, "local_path", lambda uri: str(tmp_path))
1326
+
1327
+ app, left, right = await _open_confirm(tmp_path, "")
1328
+ async with app.run_test(size=(140, 30)) as pilot:
1329
+ await _skip_boot(pilot)
1330
+ await pilot.press("p")
1331
+ await pilot.pause()
1332
+ table = app.screen.query_one("#places-table")
1333
+ row = [i for i, pl in enumerate(app.screen.places) if pl.path == "smb://nas/HD1/"][0]
1334
+ table.move_cursor(row=row)
1335
+ await pilot.press("l")
1336
+ await pilot.pause()
1337
+ assert isinstance(app.screen, ChoiceScreen) and app.screen.options[0] == "Anonymous (no login)"
1338
+ await pilot.press("enter") # Anonymous
1339
+ await pilot.pause()
1340
+ await pilot.pause()
1341
+ assert seen == {"reset": "smb://nas/HD1/", "mount": ("smb://nas/HD1/", True)}
1342
+ assert netlocations.is_anonymous("smb://nas/HD1/")
@@ -252,10 +252,15 @@ def test_backend_missing_checks_gvfs_mount_files(monkeypatch, tmp_path):
252
252
  mounts = tmp_path / "gvfs" / "mounts"
253
253
  mounts.mkdir(parents=True)
254
254
  monkeypatch.setattr(netlocations, "_gvfs_mount_dirs", lambda: [str(mounts)])
255
+ monkeypatch.setattr(netlocations, "_fuse_present", lambda: True)
255
256
  assert netlocations.backend_missing("smb") is True
256
257
  (mounts / "smb.mount").write_text("")
257
258
  assert netlocations.backend_missing("smb") is False
258
259
  assert netlocations.backend_missing("sftp") is True
260
+ monkeypatch.setattr(
261
+ netlocations, "_fuse_present", lambda: False
262
+ ) # backend ok but no folder to open the share in
263
+ assert netlocations.backend_missing("smb") is True
259
264
 
260
265
 
261
266
  def test_backend_missing_is_false_on_macos_or_without_gio(monkeypatch):
@@ -270,7 +275,13 @@ def test_backend_install_command_per_package_manager(monkeypatch):
270
275
  monkeypatch.setattr(
271
276
  netlocations.shutil, "which", lambda name: "/usr/bin/" + name if name == "apt-get" else None
272
277
  )
273
- assert netlocations.backend_install_command() == ["sudo", "apt-get", "install", "gvfs-backends"]
278
+ assert netlocations.backend_install_command() == [
279
+ "sudo",
280
+ "apt-get",
281
+ "install",
282
+ "gvfs-backends",
283
+ "gvfs-fuse",
284
+ ]
274
285
  monkeypatch.setattr(
275
286
  netlocations.shutil, "which", lambda name: "/usr/bin/pacman" if name == "pacman" else None
276
287
  )
@@ -282,7 +293,58 @@ def test_backend_install_command_per_package_manager(monkeypatch):
282
293
 
283
294
  def test_install_backend_reports_exit_code(monkeypatch):
284
295
  monkeypatch.setattr(netlocations, "backend_install_command", lambda: ["sudo", "true"])
285
- monkeypatch.setattr(netlocations.subprocess, "run", lambda cmd: subprocess.CompletedProcess(cmd, 0))
296
+ monkeypatch.setattr(netlocations.subprocess, "run", lambda cmd, **kw: subprocess.CompletedProcess(cmd, 0))
286
297
  assert netlocations.install_backend() is True
287
- monkeypatch.setattr(netlocations.subprocess, "run", lambda cmd: subprocess.CompletedProcess(cmd, 1))
298
+ monkeypatch.setattr(netlocations.subprocess, "run", lambda cmd, **kw: subprocess.CompletedProcess(cmd, 1))
288
299
  assert netlocations.install_backend() is False
300
+
301
+
302
+ def test_anonymous_location_is_saved_and_mounted_without_prompting(monkeypatch, tmp_path):
303
+ monkeypatch.setattr(netlocations.sys, "platform", "linux")
304
+ monkeypatch.setattr(netlocations, "NETWORK_FILE", str(tmp_path / "network.json"))
305
+ netlocations.save_location("smb://nas/Public/", anonymous=True)
306
+ loc = netlocations.load_locations()[0]
307
+ assert loc.anonymous is True and loc.user is None
308
+ assert netlocations.is_anonymous("smb://nas/Public/") and not netlocations.is_anonymous("smb://other/")
309
+ calls = []
310
+
311
+ def fake_gio(args, interactive=False):
312
+ calls.append((args, interactive))
313
+ return _cp()
314
+
315
+ monkeypatch.setattr(netlocations, "_gio", fake_gio)
316
+ assert netlocations.mount("smb://nas/Public/", interactive=True) == (True, "") # saved as anonymous
317
+ assert calls == [(["mount", "--anonymous", "smb://nas/Public/"], False)]
318
+ calls.clear()
319
+ netlocations.mount("smb://nas/Public/", anonymous=False) # explicit user login wins over the saved flag
320
+ assert calls == [(["mount", "smb://nas/Public/"], False)]
321
+
322
+
323
+ def test_anonymous_list_shares_mounts_the_server_first(monkeypatch):
324
+ monkeypatch.setattr(netlocations.sys, "platform", "linux")
325
+ calls = []
326
+
327
+ def fake_gio(args, interactive=False):
328
+ calls.append(args)
329
+ return _cp(stdout="Public\nHD1\n") if args[0] == "list" else _cp()
330
+
331
+ monkeypatch.setattr(netlocations, "_gio", fake_gio)
332
+ assert netlocations.list_shares("nas", anonymous=True) == (["Public", "HD1"], "")
333
+ assert calls == [["mount", "--anonymous", "smb://nas/"], ["list", "smb://nas/"]]
334
+ monkeypatch.setattr(
335
+ netlocations, "_gio", lambda args, interactive=False: _cp(returncode=1, stderr="denied")
336
+ )
337
+ assert netlocations.list_shares("nas", anonymous=True) == (None, "denied")
338
+
339
+
340
+ def test_reset_login_unmounts_the_share_and_the_server(monkeypatch):
341
+ monkeypatch.setattr(netlocations.sys, "platform", "linux")
342
+ calls = []
343
+ monkeypatch.setattr(
344
+ netlocations, "_gio", lambda args, interactive=False: calls.append(args) or _cp(returncode=1)
345
+ )
346
+ netlocations.reset_login("smb://nas/Public/") # "not mounted" errors are fine
347
+ assert calls == [["mount", "-u", "smb://nas/Public/"], ["mount", "-u", "smb://nas/"]]
348
+ calls.clear()
349
+ netlocations.reset_login("smb://nas/")
350
+ assert calls == [["mount", "-u", "smb://nas/"]]
@@ -62,9 +62,14 @@ def test_fake_latest_env_shows_an_update_without_a_release(monkeypatch):
62
62
 
63
63
 
64
64
  def test_update_commands_per_install_kind():
65
- assert updater.update_command("pipx", "2.0.0") == ["pipx", "upgrade", "commandertui"]
65
+ assert updater.update_command("pipx", "2.0.0") == [
66
+ "pipx",
67
+ "upgrade",
68
+ "--pip-args=--no-cache-dir",
69
+ "commandertui",
70
+ ]
66
71
  pip = updater.update_command("pip", "2.0.0")
67
- assert pip[1:] == ["-m", "pip", "install", "--upgrade", "commandertui"]
72
+ assert pip[1:] == ["-m", "pip", "install", "--upgrade", "--no-cache-dir", "commandertui"]
68
73
  assert updater.update_command("source", "2.0.0") is None
69
74
  arch = updater.update_command("pacman", "2.0.0")
70
75
  assert arch[:2] == ["bash", "-c"]
@@ -116,3 +121,30 @@ def test_check_status_tells_ok_new_skipped_and_offline(monkeypatch):
116
121
  assert updater.check_status(current="1.1.0") == (None, "skipped")
117
122
  monkeypatch.setattr(updater, "fetch_latest", lambda: None)
118
123
  assert updater.check_status(current="1.1.0") == (None, "offline")
124
+
125
+
126
+ def _info(latest="2.0.0", install="pipx"):
127
+ return updater.UpdateInfo(current="1.0.0", latest=latest, install=install)
128
+
129
+
130
+ def test_run_update_requires_the_new_version_to_be_installed(monkeypatch):
131
+ import subprocess
132
+
133
+ monkeypatch.setattr(updater.subprocess, "run", lambda cmd, **kw: subprocess.CompletedProcess(cmd, 0))
134
+ monkeypatch.setattr(updater, "installed_version", lambda: "2.0.0")
135
+ assert updater.run_update(_info()) is True
136
+ # pipx said "already up to date" (exit 0) but the old version is still there
137
+ monkeypatch.setattr(updater, "installed_version", lambda: "1.0.0")
138
+ assert updater.run_update(_info()) is False
139
+ # version unknown: trust the exit code
140
+ monkeypatch.setattr(updater, "installed_version", lambda: None)
141
+ assert updater.run_update(_info()) is True
142
+
143
+
144
+ def test_run_update_fails_on_nonzero_exit_without_checking_version(monkeypatch):
145
+ import subprocess
146
+
147
+ monkeypatch.setattr(updater.subprocess, "run", lambda cmd, **kw: subprocess.CompletedProcess(cmd, 1))
148
+ monkeypatch.setattr(updater, "installed_version", lambda: "2.0.0")
149
+ assert updater.run_update(_info()) is False
150
+ assert updater.run_update(_info(install="source")) is False
@@ -1 +0,0 @@
1
- __version__ = "1.4.1"
File without changes
File without changes