hbkit 0.4.6__tar.gz → 0.4.7__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hbkit
3
- Version: 0.4.6
3
+ Version: 0.4.7
4
4
  Summary: Recover files from Synology Hyper Backup (.hbk) archives without Synology software
5
5
  Project-URL: Homepage, https://github.com/YordiLorenzo/hbkit
6
6
  Project-URL: Source, https://github.com/YordiLorenzo/hbkit
@@ -27,7 +27,9 @@ Provides-Extra: dev
27
27
  Requires-Dist: pytest>=7; extra == 'dev'
28
28
  Description-Content-Type: text/markdown
29
29
 
30
- # hbkit
30
+ <p align="center">
31
+ <img src="https://raw.githubusercontent.com/YordiLorenzo/hbkit/main/docs/images/banner.png" alt="hbkit: your backup, without the NAS" width="100%">
32
+ </p>
31
33
 
32
34
  [![PyPI](https://img.shields.io/pypi/v/hbkit)](https://pypi.org/project/hbkit/)
33
35
  [![CI](https://github.com/YordiLorenzo/hbkit/actions/workflows/ci.yml/badge.svg)](https://github.com/YordiLorenzo/hbkit/actions/workflows/ci.yml)
@@ -39,6 +41,8 @@ Point it at a backup on a local disk, an external drive, or a network mount, bro
39
41
  a tree, and pull out what you want. Works headless on Linux and macOS, including Apple Silicon, where Synology's
40
42
  own Hyper Backup Explorer is awkward or unavailable.
41
43
 
44
+ Website: **[yordilorenzo.github.io/hbkit](https://yordilorenzo.github.io/hbkit/)**
45
+
42
46
  ```sh
43
47
  brew install YordiLorenzo/tap/hbkit # macOS / Linux, pulls in liblz4 for you
44
48
 
@@ -77,22 +81,7 @@ case is a loud failure naming the file.
77
81
 
78
82
  ## The TUI
79
83
 
80
- ```
81
- ⭘ Hyper Backup Recovery 17:11:17
82
- ┌──────────────────────────────────────────────────────────────┐ │
83
- │ search filename… (/) │ │ Selection
84
- └──────────────────────────────────────────────────────────────┘ │ 197,607 files
85
- ▼ ◪ 📁 NAS Volume 1 4.4T 499,745 │ 625.0G in 1 item(s)
86
- ├─ ▶ ☐ 📁 Archive 2022 24.2G 358 │
87
- ├─ ▶ ☐ 📁 Backups 23.5G 63,693 │ Destination
88
- ├─ ▶ ☐ 📁 Video Projects 1.9T 64,473 │ ┌──────────────────────────┐
89
- ├─ ▶ ☐ 📁 Media Library 1.3T 66,629 │ │ ~/restore │
90
- ├─ ▼ ☑ 📁 Photo Libraries 625.0G 197,607 │ └──────────────────────────┘
91
- │ ├─ ▶ ☑ 📁 Photos Library - Laptop… 28.8G 76,844 │ ⚠ needs 625.0G, only 70.3G free
92
- │ ├─ ▶ ☑ 📁 Photos Library - Old Backup… 19.8G 15,505 │
93
- │ Start recovery
94
- a All n Clear d Destination r Recover / Search q Quit │
95
- ```
84
+ ![hbkit's terminal browser: a backup's shares as a folder tree with sizes and file counts, two photo folders ticked, and a panel with the selection, the destination's free space and a Start recovery button](https://raw.githubusercontent.com/YordiLorenzo/hbkit/main/docs/images/tui.png)
96
85
 
97
86
  `space` tick · `a` all · `n` clear · `/` search · `d` destination · `r` recover ·
98
87
  `ctrl+q` quit (plain `q` types into whichever field has focus, so quit is ctrl+q).
@@ -1,4 +1,6 @@
1
- # hbkit
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/YordiLorenzo/hbkit/main/docs/images/banner.png" alt="hbkit: your backup, without the NAS" width="100%">
3
+ </p>
2
4
 
3
5
  [![PyPI](https://img.shields.io/pypi/v/hbkit)](https://pypi.org/project/hbkit/)
4
6
  [![CI](https://github.com/YordiLorenzo/hbkit/actions/workflows/ci.yml/badge.svg)](https://github.com/YordiLorenzo/hbkit/actions/workflows/ci.yml)
@@ -10,6 +12,8 @@ Point it at a backup on a local disk, an external drive, or a network mount, bro
10
12
  a tree, and pull out what you want. Works headless on Linux and macOS, including Apple Silicon, where Synology's
11
13
  own Hyper Backup Explorer is awkward or unavailable.
12
14
 
15
+ Website: **[yordilorenzo.github.io/hbkit](https://yordilorenzo.github.io/hbkit/)**
16
+
13
17
  ```sh
14
18
  brew install YordiLorenzo/tap/hbkit # macOS / Linux, pulls in liblz4 for you
15
19
 
@@ -48,22 +52,7 @@ case is a loud failure naming the file.
48
52
 
49
53
  ## The TUI
50
54
 
51
- ```
52
- ⭘ Hyper Backup Recovery 17:11:17
53
- ┌──────────────────────────────────────────────────────────────┐ │
54
- │ search filename… (/) │ │ Selection
55
- └──────────────────────────────────────────────────────────────┘ │ 197,607 files
56
- ▼ ◪ 📁 NAS Volume 1 4.4T 499,745 │ 625.0G in 1 item(s)
57
- ├─ ▶ ☐ 📁 Archive 2022 24.2G 358 │
58
- ├─ ▶ ☐ 📁 Backups 23.5G 63,693 │ Destination
59
- ├─ ▶ ☐ 📁 Video Projects 1.9T 64,473 │ ┌──────────────────────────┐
60
- ├─ ▶ ☐ 📁 Media Library 1.3T 66,629 │ │ ~/restore │
61
- ├─ ▼ ☑ 📁 Photo Libraries 625.0G 197,607 │ └──────────────────────────┘
62
- │ ├─ ▶ ☑ 📁 Photos Library - Laptop… 28.8G 76,844 │ ⚠ needs 625.0G, only 70.3G free
63
- │ ├─ ▶ ☑ 📁 Photos Library - Old Backup… 19.8G 15,505 │
64
- │ Start recovery
65
- a All n Clear d Destination r Recover / Search q Quit │
66
- ```
55
+ ![hbkit's terminal browser: a backup's shares as a folder tree with sizes and file counts, two photo folders ticked, and a panel with the selection, the destination's free space and a Start recovery button](https://raw.githubusercontent.com/YordiLorenzo/hbkit/main/docs/images/tui.png)
67
56
 
68
57
  `space` tick · `a` all · `n` clear · `/` search · `d` destination · `r` recover ·
69
58
  `ctrl+q` quit (plain `q` types into whichever field has focus, so quit is ctrl+q).
@@ -0,0 +1,22 @@
1
+ # Art
2
+
3
+ Every image in `docs/images/` and `site/` is built from the files here, so the screenshots
4
+ can be redone whenever the TUI changes. Run from anywhere:
5
+
6
+ ```sh
7
+ pip install -e . fonttools brotli # hbkit itself renders the TUI screenshot
8
+ brew install webp imagemagick # plus Google Chrome, used headless
9
+ contrib/art/render.sh
10
+ ```
11
+
12
+ | source | builds |
13
+ |---|---|
14
+ | `site/images/icon.svg` | the icon, drawn by hand; everything else derives from it |
15
+ | `logo.py` | `docs/images/logo.svg` and `logo-light.svg`: the icon and the wordmark, in Outfit SemiBold as outlines |
16
+ | `render_tui.py`, `tui.html` | `docs/images/tui.png` and the site's `tui-*.webp`: hbkit's real browser screen over a made-up archive |
17
+ | `banner.html` | `docs/images/banner.png`, the README header |
18
+ | `social.html` | `site/social-preview.png`, the link preview card |
19
+ | `icon.html` | `docs/images/icon-1024.png` |
20
+
21
+ The demo archive in `render_tui.py` is invented: no real backup or personal data goes into
22
+ any image. Outfit is under the SIL Open Font License (`site/fonts/OFL.txt`).
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hbkit"
7
- version = "0.4.6"
7
+ version = "0.4.7"
8
8
  description = "Recover files from Synology Hyper Backup (.hbk) archives without Synology software"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  """hbkit - recover files from Synology Hyper Backup (.hbk) archives without Synology software."""
2
2
 
3
- __version__ = "0.4.6"
3
+ __version__ = "0.4.7"
4
4
 
5
5
  from .archive import (Archive, NeedPassword, UnsupportedArchive, # noqa: F401
6
6
  find_archive_root, is_archive)
@@ -526,14 +526,19 @@ class BrowserScreen(Screen):
526
526
  (f"{n}", "bold"), (" item(s)", "dim")))
527
527
  self.update_space()
528
528
 
529
+ def dest(self) -> str:
530
+ """The destination as typed, with ~ and relative paths resolved as a shell would."""
531
+ return os.path.abspath(os.path.expanduser(self.query_one("#dest", Input).value))
532
+
529
533
  def update_space(self):
530
- probe = self.query_one("#dest", Input).value
531
- while probe and not os.path.exists(probe):
532
- probe = os.path.dirname(probe)
533
534
  w = self.query_one("#space-info", Static)
534
- if not probe:
535
- w.update(Text("invalid path", style="bold red"))
536
- return
535
+ probe = self.dest()
536
+ while not os.path.exists(probe):
537
+ parent = os.path.dirname(probe)
538
+ if parent == probe: # a drive that isn't there
539
+ w.update(Text("invalid path", style="bold red"))
540
+ return
541
+ probe = parent
537
542
  free = shutil.disk_usage(probe).free
538
543
  _, need, _ = self.sel.totals()
539
544
  if need > free:
@@ -595,7 +600,7 @@ class BrowserScreen(Screen):
595
600
  if not self.sel.chosen:
596
601
  self.notify("select something first — space to tick", severity="warning")
597
602
  return
598
- dest = os.path.abspath(os.path.expanduser(self.query_one("#dest", Input).value))
603
+ dest = self.dest()
599
604
  files = self.sel.files()
600
605
  if not files:
601
606
  self.notify("selection contains no extractable files", severity="error")
@@ -590,3 +590,40 @@ def test_lazycats_lists_shards_before_any_are_opened():
590
590
  0: "/nonexistent/file_chunk0.index"})
591
591
  assert cats.known == [0, 4]
592
592
  assert len(cats) == 0, "listing shards must not open them"
593
+
594
+
595
+ @pytest.mark.parametrize("typed", ["~/hbkit-test-not-there/restore", "restore/here"])
596
+ def test_tui_free_space_follows_home_and_relative_destinations(tmp_path, monkeypatch, typed):
597
+ """Typing ~/restore showed "invalid path" under the destination, though the restore
598
+ itself expanded ~ fine. A relative path did the same."""
599
+ import asyncio
600
+ import sqlite3
601
+ from types import SimpleNamespace
602
+
603
+ from hbkit import tui
604
+
605
+ db = tmp_path / "index.db"
606
+ con = sqlite3.connect(db)
607
+ con.execute("CREATE TABLE node(id INTEGER PRIMARY KEY, parent INTEGER, name TEXT, "
608
+ "isdir INTEGER, size INTEGER, nfiles INTEGER, ovf INTEGER, mtime INTEGER, "
609
+ "share TEXT)")
610
+ con.execute("INSERT INTO node VALUES (1, NULL, 'share', 1, 10, 1, NULL, 0, 'share')")
611
+ con.commit()
612
+ con.close()
613
+ monkeypatch.chdir(tmp_path)
614
+
615
+ class App(tui.HBKApp):
616
+ def on_mount(self, event):
617
+ event.prevent_default() # skip the archive picker
618
+ self.push_screen(tui.BrowserScreen(SimpleNamespace(root="demo"), tui.Index(str(db))))
619
+
620
+ async def space_line():
621
+ app = App(1)
622
+ async with app.run_test() as pilot:
623
+ await pilot.pause()
624
+ app.screen.query_one("#dest", tui.Input).value = typed
625
+ await pilot.pause()
626
+ return str(app.screen.query_one("#space-info", tui.Static).render())
627
+
628
+ line = asyncio.run(space_line())
629
+ assert "free" in line and "invalid" not in line, line
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