diff --git a/.gitignore b/.gitignore
index e69de29..9ac7a1b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -0,0 +1,10 @@
+__pycache__/
+.vscode/
+.venv/
+.env
+.ruff_cache/
+.pytest_cache/
+.claude/
+AGENTS.md
+DESIGN_DOCUMENT.md
+.mypy_cache/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..dcb5636
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,52 @@
+# Changelog
+
+## 1.0.0 (2026-01-30)
+
+První kompletní verze aplikace Vault.
+
+### Core
+
+- **file_entry.py** - Immutable dataclass pro reprezentaci souboru (cesta, SHA-256 hash, velikost, timestampy)
+- **manifest.py** - Správa vault metadat (vault_id, lokace replik, seznam souborů). Ukládání/načítání z `.vault/manifest.json`. Deduplikace lokací s resolved cestami
+- **lock.py** - Exkluzivní přístup k vault pomocí fcntl (LOCK_EX). Detekce PID vlastníka
+- **image_manager.py** - Vytváření sparse .vault souborů, formátování exFAT (mkfs.exfat), resize (truncate + fsck), dotaz na info
+- **container.py** - Mount/unmount přes udisksctl (loop device, bez root oprávnění)
+- **file_watcher.py** - Detekce změn souborů přes watchdog/inotify (create, modify, delete, move). Podpora ignore patterns
+- **file_sync.py** - Kopírování souborů s progress callbackem (chunked, 1MB). Sync na úrovni jednotlivých souborů (SHA-256 + timestamp porovnání)
+- **sync_manager.py** - Orchestrace synchronizace mezi replikami. Real-time propagace změn přes file watcher. Manifest-based porovnání pro reconnect sync. Pause/resume
+- **vault.py** - Hlavní třída Vault orchestrující vše:
+ - Otevření/zavření vault s lock managementem
+ - Automatické mountování sekundárních replik z manifestu
+ - Přidání/odebrání replik s full sync
+ - Manuální synchronizace
+ - Resize všech replik (unmount → resize → remount)
+ - Detekce zaplnění (>90% varování)
+ - Polling dostupnosti replik (30s) s auto-reconnect
+ - Konzistentní porovnání cest pomocí Path.resolve()
+ - Graceful shutdown (SIGINT/SIGTERM)
+
+### GUI (System Tray)
+
+- **tray_app.py** - System tray daemon s kontextovým menu:
+ - Vytvořit/otevřít/zavřít vault
+ - Přidat repliku, spravovat repliky
+ - Synchronizovat, zvětšit vault
+ - Stavy ikon: šedá (zavřeno), zelená (otevřeno), žlutá (částečné repliky), modrá (sync), červená (chyba)
+ - 5s status update timer, 30s replica check timer
+ - Varování při zaplnění >90%
+- **notifications.py** - Systémové notifikace přes notify-send
+- **dialogs/new_vault.py** - Dialog pro vytvoření vault (název, cesta, velikost s quick buttony 1/5/10/50 GB)
+- **dialogs/open_vault.py** - Dialog pro otevření existujícího .vault souboru
+- **dialogs/manage_replicas.py** - Tabulka replik se statusem (primární/sekundární, připojeno/odpojeno) a tlačítkem pro odebrání
+- **dialogs/sync_progress.py** - Progress dialog se progress barem, aktuálním souborem, logem a cancel tlačítkem
+- **dialogs/resize_vault.py** - Dialog pro zvětšení vault s aktuálním využitím a spinnerem pro novou velikost
+
+### Testy
+
+- 130 unit testů pokrývajících všechny core moduly
+- test_file_entry, test_manifest, test_lock, test_image_manager, test_container
+- test_file_watcher, test_file_sync, test_sync_manager, test_vault
+
+### Bugfix
+
+- Oprava duplicitního zobrazení replik při otevření sekundárního kontejneru - všechny cesty se nyní porovnávají přes `Path.resolve()` pro kanonickou formu
diff --git a/PROJECT.md b/PROJECT.md
new file mode 100644
index 0000000..4178a6a
--- /dev/null
+++ b/PROJECT.md
@@ -0,0 +1,228 @@
+# Vault - Project Documentation
+
+## About
+
+Resilientní úložiště s redundantní strukturou. Program spravuje 2+ kontejnerů (disk image souborů), které obsahují:
+- Souborovou strukturu přístupnou přes FUSE mount
+- Metadata o lokacích ostatních kontejnerů
+- Informace o verzích
+- Synchronizované kopie stejného obsahu napříč všemi kontejnery
+
+**Klíčový princip:** Aplikace běží jako tray daemon, mountuje vault jako běžnou složku. Uživatel pracuje se svým oblíbeným file managerem (Nautilus, Dolphin, Thunar). Aplikace na pozadí zajišťuje synchronizaci mezi replikami.
+
+**Platforma:** Pouze Linux (FUSE mount). Windows/Mac možná později.
+
+---
+
+## Specifikace
+
+### Formát úložiště
+- **Kontejner:** Raw disk image (.vault) se sparse alokací
+- **Filesystem:** exFAT (cross-platform, bez 4GB limitu)
+- **Přípona:** `.vault`
+- **Velikost:** Uživatel zadá při vytvoření
+- **Struktura uvnitř:**
+ ```
+ myvault.vault (sparse soubor)
+ └── [exFAT filesystem]
+ ├── .vault/
+ │ ├── manifest.json
+ │ └── lock # Lock soubor pro exkluzivní přístup
+ ├── documents/
+ │ └── file.txt
+ └── photos/
+ └── image.jpg
+ ```
+
+### Mount
+- **Metoda:** udisksctl (loop device + mount, bez root)
+- **Mount point:** Automatický (udisksctl) nebo uživatelem zvolený
+- **Exkluzivní přístup:** Lock soubor s fcntl - pouze jedna instance může mít vault otevřený
+
+### Čitelnost bez aplikace (bez Vault app)
+- `sudo mount -o loop myvault.vault /mnt/vault`
+
+### Resize
+- Při zaplnění: varování uživateli (notifikace při >90% využití)
+- Uživatel rozhodne o zvětšení přes GUI dialog
+
+### Umístění kontejnerů
+- Kdekoli s RW přístupem (různé disky, síťová umístění, USB, cloud)
+- Každý kontejner obsahuje metadata s cestami k ostatním kontejnerům
+
+### Synchronizace
+- **Úroveň:** Jednotlivé soubory (NE celé .vault obrazy)
+- **Interní Python implementace** (bez rsync)
+- **Hash** (SHA-256) pro detekci změn obsahu
+- **Timestamp** pro určení master verze (novější vyhrává)
+- Předpoklad: většinou budou všechny kontejnery dostupné → synchronní zápis
+- Fallback: při nedostupnosti některého kontejneru se při reconnectu synchronizuje podle timestampu
+- Chunked copy s progress callbackem pro velké soubory
+- **Background sync:** Změny se detekují přes inotify/watchdog a propagují automaticky
+
+**Jak sync funguje:**
+1. Všechny repliky jsou mountnuté současně (každá do jiného temp mount pointu)
+2. Uživatel vidí pouze hlavní mount point
+3. Při změně souboru → soubor se zkopíruje do všech mountnutých replik
+4. Při reconnect nedostupné repliky → porovnání manifestů → kopírování pouze změněných souborů
+
+### GUI
+- **Framework:** PySide6
+- **Typ:** System tray aplikace (daemon)
+- **Jazyk UI:** Čeština
+- **Uživatel pracuje:** Se standardním file managerem OS
+
+---
+
+## GUI - System Tray App
+
+### Tray ikona - stavy
+| Ikona | Stav |
+|-------|------|
+| Zelená | Vault otevřen, vše synchronizováno |
+| Modrá | Synchronizace probíhá |
+| Žlutá | Některé repliky nedostupné |
+| Červená | Chyba (vault zaplněn, sync selhala, atd.) |
+| Šedá | Žádný vault otevřen |
+
+### Tray menu
+```
+My Vault (3/3 replik online) [status]
+────────────────────────────────────
+Otevřít složku [otevře mount point v file manageru]
+────────────────────────────────────
+Vytvořit nový vault...
+Otevřít vault...
+Zavřít vault
+────────────────────────────────────
+Přidat repliku...
+Spravovat repliky...
+────────────────────────────────────
+Synchronizovat
+Zvětšit vault...
+────────────────────────────────────
+Ukončit
+```
+
+### Dialogy
+- **Nový vault:** Název, cesta, velikost (s quick buttony 1/5/10/50 GB)
+- **Otevřít vault:** Výběr .vault souboru
+- **Spravovat repliky:** Tabulka replik se statusem a tlačítkem pro odebrání
+- **Zvětšit vault:** Aktuální využití, nová velikost
+- **Sync progress:** Progress bar, aktuální soubor, log, cancel
+
+---
+
+## Architektura
+
+```
+Vault/
+├── Vault.py # Entry point
+├── pyproject.toml # Poetry konfigurace
+├── src/
+│ ├── core/ # Business logika (BEZ UI importů!)
+│ │ ├── vault.py # Hlavní třída Vault - orchestrace
+│ │ ├── container.py # Mount/unmount přes udisksctl
+│ │ ├── image_manager.py # Vytváření/resize sparse .vault souborů
+│ │ ├── lock.py # Exkluzivní přístup (fcntl)
+│ │ ├── sync_manager.py # Synchronizace mezi replikami
+│ │ ├── file_watcher.py # watchdog/inotify pro detekci změn
+│ │ ├── file_sync.py # Kopírování souborů s progress callback
+│ │ ├── manifest.py # Metadata - lokace, verze, soubory
+│ │ └── file_entry.py # Reprezentace souboru (path, hash, timestamp)
+│ └── ui/
+│ ├── tray_app.py # System tray aplikace + menu
+│ ├── notifications.py # System notifikace (notify-send)
+│ └── dialogs/
+│ ├── new_vault.py
+│ ├── open_vault.py
+│ ├── manage_replicas.py
+│ ├── resize_vault.py
+│ └── sync_progress.py
+└── tests/
+ ├── test_file_entry.py
+ ├── test_manifest.py
+ ├── test_lock.py
+ ├── test_image_manager.py
+ ├── test_container.py
+ ├── test_file_watcher.py
+ ├── test_file_sync.py
+ ├── test_sync_manager.py
+ └── test_vault.py
+```
+
+---
+
+## Metadata struktura (.vault/manifest.json)
+
+```json
+{
+ "vault_id": "550e8400-e29b-41d4-a716-446655440000",
+ "vault_name": "My Vault",
+ "version": 1,
+ "created": "2026-01-28T10:30:00Z",
+ "last_modified": "2026-01-28T15:45:00Z",
+ "image_size_mb": 10240,
+ "locations": [
+ {
+ "path": "/mnt/disk1/myvault.vault",
+ "last_seen": "2026-01-28T15:45:00Z",
+ "status": "active"
+ },
+ {
+ "path": "/mnt/usb/myvault.vault",
+ "last_seen": "2026-01-28T15:45:00Z",
+ "status": "active"
+ }
+ ],
+ "files": [
+ {
+ "path": "documents/file.txt",
+ "hash": "sha256:e3b0c44...",
+ "size": 1234,
+ "created": "2026-01-28T10:30:00Z",
+ "modified": "2026-01-28T14:20:00Z"
+ }
+ ]
+}
+```
+
+---
+
+## Závislosti
+
+### Python (Poetry)
+```
+PySide6>=6.10.1 # GUI framework
+pyfuse3>=3.4.2 # FUSE binding (nepoužito - udisksctl místo FUSE)
+watchdog>=6.0.0 # File system events (inotify)
+loguru>=0.7.3 # Logging
+python-dotenv>=1.2.1 # Environment variables
+
+# Dev
+pytest>=9.0.2
+pytest-cov>=7.0.0
+ruff>=0.14.14
+mypy>=1.19.1
+```
+
+### Systémové závislosti (Linux)
+```bash
+sudo apt install udisks2 exfatprogs
+```
+
+---
+
+## Aktuální stav
+
+**Fáze 1-5: Dokončeno**
+
+- Kompletní core logika (vault, container, image_manager, manifest, file_entry, lock, sync_manager, file_watcher, file_sync)
+- System tray GUI s menu, notifikacemi, dialogy
+- Správa replik (přidání, odebrání, tabulka se statusy)
+- Automatická detekce dostupnosti replik (30s polling) s auto-reconnect
+- Resize vault přes GUI dialog
+- Detekce zaplnění s varováním při >90%
+- Graceful shutdown (SIGINT/SIGTERM)
+- 130 testů, vše passing
+- ruff + mypy clean
diff --git a/Vault.py b/Vault.py
index d30a0db..2eb4d60 100644
--- a/Vault.py
+++ b/Vault.py
@@ -1,37 +1,32 @@
-import zipfile
-import os
+"""Vault - Resilient storage application.
-def vytvor_zip(zip_path, soubory):
- with zipfile.ZipFile(zip_path, 'w') as zipf:
- for soubor in soubory:
- zipf.write(soubor)
+Entry point for the Vault application.
+"""
-def extrahuj_zip(zip_path, cilovy_adresar):
- with zipfile.ZipFile(zip_path, 'r') as zipf:
- zipf.extractall(cilovy_adresar)
+import sys
-def vypis_obsah_zip(zip_path):
- with zipfile.ZipFile(zip_path, 'r') as zipf:
- for nazev in zipf.namelist():
- print(nazev)
+from loguru import logger
-def pridej_do_zip(zip_path, soubor):
- with zipfile.ZipFile(zip_path, 'a') as zipf:
- zipf.write(soubor)
-def prepis_soubor_v_zipu(zip_path, novy_soubor, jmeno_v_zipu=None):
- jmeno_v_zipu = jmeno_v_zipu or os.path.basename(novy_soubor)
- temp_zip = zip_path + '.tmp'
+def setup_logging() -> None:
+ """Configure loguru logging."""
+ logger.remove()
+ logger.add(
+ sys.stderr,
+ format="{time:HH:mm:ss} | {level: <8} | {message}",
+ level="INFO",
+ )
- with zipfile.ZipFile(zip_path, 'r') as zip_read, \
- zipfile.ZipFile(temp_zip, 'w') as zip_write:
- for item in zip_read.infolist():
- if item.filename != jmeno_v_zipu:
- data = zip_read.read(item.filename)
- zip_write.writestr(item, data)
- zip_write.write(novy_soubor, arcname=jmeno_v_zipu)
- os.replace(temp_zip, zip_path)
+def main() -> int:
+ """Main entry point."""
+ setup_logging()
+ logger.info("Vault starting...")
-#vytvor_zip("test.vf", ["material_hardness.xlsx", "material_denominations.xlsx"])
-vypis_obsah_zip("test.vf")
\ No newline at end of file
+ from src.ui.tray_app import main as tray_main
+
+ return tray_main()
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/poetry.lock b/poetry.lock
new file mode 100644
index 0000000..30d454f
--- /dev/null
+++ b/poetry.lock
@@ -0,0 +1,816 @@
+# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
+
+[[package]]
+name = "attrs"
+version = "25.4.0"
+description = "Classes Without Boilerplate"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"},
+ {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"},
+]
+
+[[package]]
+name = "cffi"
+version = "2.0.0"
+description = "Foreign Function Interface for Python calling C code."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+markers = "os_name == \"nt\" and implementation_name != \"pypy\""
+files = [
+ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
+ {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"},
+ {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"},
+ {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"},
+ {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"},
+ {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"},
+ {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"},
+ {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"},
+ {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"},
+ {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"},
+ {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"},
+ {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"},
+ {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"},
+ {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"},
+ {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"},
+ {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"},
+ {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"},
+ {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"},
+ {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"},
+ {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"},
+ {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"},
+ {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"},
+ {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"},
+ {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"},
+ {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"},
+ {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"},
+ {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"},
+ {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"},
+ {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"},
+ {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"},
+ {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"},
+ {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"},
+ {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"},
+ {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"},
+ {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"},
+ {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"},
+ {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"},
+ {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"},
+ {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"},
+ {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"},
+ {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"},
+ {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"},
+ {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"},
+ {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"},
+ {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"},
+ {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"},
+ {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"},
+ {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"},
+ {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"},
+ {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"},
+ {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"},
+ {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"},
+ {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"},
+ {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"},
+ {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"},
+ {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"},
+ {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"},
+ {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"},
+ {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"},
+]
+
+[package.dependencies]
+pycparser = {version = "*", markers = "implementation_name != \"PyPy\""}
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+groups = ["main", "dev"]
+markers = "sys_platform == \"win32\""
+files = [
+ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+
+[[package]]
+name = "coverage"
+version = "7.13.2"
+description = "Code coverage measurement for Python"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "coverage-7.13.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4af3b01763909f477ea17c962e2cca8f39b350a4e46e3a30838b2c12e31b81b"},
+ {file = "coverage-7.13.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:36393bd2841fa0b59498f75466ee9bdec4f770d3254f031f23e8fd8e140ffdd2"},
+ {file = "coverage-7.13.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9cc7573518b7e2186bd229b1a0fe24a807273798832c27032c4510f47ffdb896"},
+ {file = "coverage-7.13.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca9566769b69a5e216a4e176d54b9df88f29d750c5b78dbb899e379b4e14b30c"},
+ {file = "coverage-7.13.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c9bdea644e94fd66d75a6f7e9a97bb822371e1fe7eadae2cacd50fcbc28e4dc"},
+ {file = "coverage-7.13.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5bd447332ec4f45838c1ad42268ce21ca87c40deb86eabd59888859b66be22a5"},
+ {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7c79ad5c28a16a1277e1187cf83ea8dafdcc689a784228a7d390f19776db7c31"},
+ {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:76e06ccacd1fb6ada5d076ed98a8c6f66e2e6acd3df02819e2ee29fd637b76ad"},
+ {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:49d49e9a5e9f4dc3d3dac95278a020afa6d6bdd41f63608a76fa05a719d5b66f"},
+ {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed2bce0e7bfa53f7b0b01c722da289ef6ad4c18ebd52b1f93704c21f116360c8"},
+ {file = "coverage-7.13.2-cp310-cp310-win32.whl", hash = "sha256:1574983178b35b9af4db4a9f7328a18a14a0a0ce76ffaa1c1bacb4cc82089a7c"},
+ {file = "coverage-7.13.2-cp310-cp310-win_amd64.whl", hash = "sha256:a360a8baeb038928ceb996f5623a4cd508728f8f13e08d4e96ce161702f3dd99"},
+ {file = "coverage-7.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:060ebf6f2c51aff5ba38e1f43a2095e087389b1c69d559fde6049a4b0001320e"},
+ {file = "coverage-7.13.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1ea8ca9db5e7469cd364552985e15911548ea5b69c48a17291f0cac70484b2e"},
+ {file = "coverage-7.13.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b780090d15fd58f07cf2011943e25a5f0c1c894384b13a216b6c86c8a8a7c508"},
+ {file = "coverage-7.13.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:88a800258d83acb803c38175b4495d293656d5fac48659c953c18e5f539a274b"},
+ {file = "coverage-7.13.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6326e18e9a553e674d948536a04a80d850a5eeefe2aae2e6d7cf05d54046c01b"},
+ {file = "coverage-7.13.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59562de3f797979e1ff07c587e2ac36ba60ca59d16c211eceaa579c266c5022f"},
+ {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:27ba1ed6f66b0e2d61bfa78874dffd4f8c3a12f8e2b5410e515ab345ba7bc9c3"},
+ {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8be48da4d47cc68754ce643ea50b3234557cbefe47c2f120495e7bd0a2756f2b"},
+ {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2a47a4223d3361b91176aedd9d4e05844ca67d7188456227b6bf5e436630c9a1"},
+ {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6f141b468740197d6bd38f2b26ade124363228cc3f9858bd9924ab059e00059"},
+ {file = "coverage-7.13.2-cp311-cp311-win32.whl", hash = "sha256:89567798404af067604246e01a49ef907d112edf2b75ef814b1364d5ce267031"},
+ {file = "coverage-7.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:21dd57941804ae2ac7e921771a5e21bbf9aabec317a041d164853ad0a96ce31e"},
+ {file = "coverage-7.13.2-cp311-cp311-win_arm64.whl", hash = "sha256:10758e0586c134a0bafa28f2d37dd2cdb5e4a90de25c0fc0c77dabbad46eca28"},
+ {file = "coverage-7.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f106b2af193f965d0d3234f3f83fc35278c7fb935dfbde56ae2da3dd2c03b84d"},
+ {file = "coverage-7.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f45d21dc4d5d6bd29323f0320089ef7eae16e4bef712dff79d184fa7330af3"},
+ {file = "coverage-7.13.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fae91dfecd816444c74531a9c3d6ded17a504767e97aa674d44f638107265b99"},
+ {file = "coverage-7.13.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:264657171406c114787b441484de620e03d8f7202f113d62fcd3d9688baa3e6f"},
+ {file = "coverage-7.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae47d8dcd3ded0155afbb59c62bd8ab07ea0fd4902e1c40567439e6db9dcaf2f"},
+ {file = "coverage-7.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a0b33e9fd838220b007ce8f299114d406c1e8edb21336af4c97a26ecfd185aa"},
+ {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3becbea7f3ce9a2d4d430f223ec15888e4deb31395840a79e916368d6004cce"},
+ {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f819c727a6e6eeb8711e4ce63d78c620f69630a2e9d53bc95ca5379f57b6ba94"},
+ {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4f7b71757a3ab19f7ba286e04c181004c1d61be921795ee8ba6970fd0ec91da5"},
+ {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b7fc50d2afd2e6b4f6f2f403b70103d280a8e0cb35320cbbe6debcda02a1030b"},
+ {file = "coverage-7.13.2-cp312-cp312-win32.whl", hash = "sha256:292250282cf9bcf206b543d7608bda17ca6fc151f4cbae949fc7e115112fbd41"},
+ {file = "coverage-7.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:eeea10169fac01549a7921d27a3e517194ae254b542102267bef7a93ed38c40e"},
+ {file = "coverage-7.13.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a5b567f0b635b592c917f96b9a9cb3dbd4c320d03f4bf94e9084e494f2e8894"},
+ {file = "coverage-7.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed75de7d1217cf3b99365d110975f83af0528c849ef5180a12fd91b5064df9d6"},
+ {file = "coverage-7.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97e596de8fa9bada4d88fde64a3f4d37f1b6131e4faa32bad7808abc79887ddc"},
+ {file = "coverage-7.13.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:68c86173562ed4413345410c9480a8d64864ac5e54a5cda236748031e094229f"},
+ {file = "coverage-7.13.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7be4d613638d678b2b3773b8f687537b284d7074695a43fe2fbbfc0e31ceaed1"},
+ {file = "coverage-7.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7f63ce526a96acd0e16c4af8b50b64334239550402fb1607ce6a584a6d62ce9"},
+ {file = "coverage-7.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:406821f37f864f968e29ac14c3fccae0fec9fdeba48327f0341decf4daf92d7c"},
+ {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ee68e5a4e3e5443623406b905db447dceddffee0dceb39f4e0cd9ec2a35004b5"},
+ {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2ee0e58cca0c17dd9c6c1cdde02bb705c7b3fbfa5f3b0b5afeda20d4ebff8ef4"},
+ {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e5bbb5018bf76a56aabdb64246b5288d5ae1b7d0dd4d0534fe86df2c2992d1c"},
+ {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a55516c68ef3e08e134e818d5e308ffa6b1337cc8b092b69b24287bf07d38e31"},
+ {file = "coverage-7.13.2-cp313-cp313-win32.whl", hash = "sha256:5b20211c47a8abf4abc3319d8ce2464864fa9f30c5fcaf958a3eed92f4f1fef8"},
+ {file = "coverage-7.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:14f500232e521201cf031549fb1ebdfc0a40f401cf519157f76c397e586c3beb"},
+ {file = "coverage-7.13.2-cp313-cp313-win_arm64.whl", hash = "sha256:9779310cb5a9778a60c899f075a8514c89fa6d10131445c2207fc893e0b14557"},
+ {file = "coverage-7.13.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5a1e41ce5df6b547cbc3d3699381c9e2c2c369c67837e716ed0f549d48e"},
+ {file = "coverage-7.13.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b01899e82a04085b6561eb233fd688474f57455e8ad35cd82286463ba06332b7"},
+ {file = "coverage-7.13.2-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:838943bea48be0e2768b0cf7819544cdedc1bbb2f28427eabb6eb8c9eb2285d3"},
+ {file = "coverage-7.13.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:93d1d25ec2b27e90bcfef7012992d1f5121b51161b8bffcda756a816cf13c2c3"},
+ {file = "coverage-7.13.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93b57142f9621b0d12349c43fc7741fe578e4bc914c1e5a54142856cfc0bf421"},
+ {file = "coverage-7.13.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f06799ae1bdfff7ccb8665d75f8291c69110ba9585253de254688aa8a1ccc6c5"},
+ {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f9405ab4f81d490811b1d91c7a20361135a2df4c170e7f0b747a794da5b7f23"},
+ {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f9ab1d5b86f8fbc97a5b3cd6280a3fd85fef3b028689d8a2c00918f0d82c728c"},
+ {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:f674f59712d67e841525b99e5e2b595250e39b529c3bda14764e4f625a3fa01f"},
+ {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c6cadac7b8ace1ba9144feb1ae3cb787a6065ba6d23ffc59a934b16406c26573"},
+ {file = "coverage-7.13.2-cp313-cp313t-win32.whl", hash = "sha256:14ae4146465f8e6e6253eba0cccd57423e598a4cb925958b240c805300918343"},
+ {file = "coverage-7.13.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9074896edd705a05769e3de0eac0a8388484b503b68863dd06d5e473f874fd47"},
+ {file = "coverage-7.13.2-cp313-cp313t-win_arm64.whl", hash = "sha256:69e526e14f3f854eda573d3cf40cffd29a1a91c684743d904c33dbdcd0e0f3e7"},
+ {file = "coverage-7.13.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:387a825f43d680e7310e6f325b2167dd093bc8ffd933b83e9aa0983cf6e0a2ef"},
+ {file = "coverage-7.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f0d7fea9d8e5d778cd5a9e8fc38308ad688f02040e883cdc13311ef2748cb40f"},
+ {file = "coverage-7.13.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080afb413be106c95c4ee96b4fffdc9e2fa56a8bbf90b5c0918e5c4449412f5"},
+ {file = "coverage-7.13.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7fc042ba3c7ce25b8a9f097eb0f32a5ce1ccdb639d9eec114e26def98e1f8a4"},
+ {file = "coverage-7.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0ba505e021557f7f8173ee8cd6b926373d8653e5ff7581ae2efce1b11ef4c27"},
+ {file = "coverage-7.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7de326f80e3451bd5cc7239ab46c73ddb658fe0b7649476bc7413572d36cd548"},
+ {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abaea04f1e7e34841d4a7b343904a3f59481f62f9df39e2cd399d69a187a9660"},
+ {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9f93959ee0c604bccd8e0697be21de0887b1f73efcc3aa73a3ec0fd13feace92"},
+ {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:13fe81ead04e34e105bf1b3c9f9cdf32ce31736ee5d90a8d2de02b9d3e1bcb82"},
+ {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d6d16b0f71120e365741bca2cb473ca6fe38930bc5431c5e850ba949f708f892"},
+ {file = "coverage-7.13.2-cp314-cp314-win32.whl", hash = "sha256:9b2f4714bb7d99ba3790ee095b3b4ac94767e1347fe424278a0b10acb3ff04fe"},
+ {file = "coverage-7.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4121a90823a063d717a96e0a0529c727fb31ea889369a0ee3ec00ed99bf6859"},
+ {file = "coverage-7.13.2-cp314-cp314-win_arm64.whl", hash = "sha256:6873f0271b4a15a33e7590f338d823f6f66f91ed147a03938d7ce26efd04eee6"},
+ {file = "coverage-7.13.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f61d349f5b7cd95c34017f1927ee379bfbe9884300d74e07cf630ccf7a610c1b"},
+ {file = "coverage-7.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a43d34ce714f4ca674c0d90beb760eb05aad906f2c47580ccee9da8fe8bfb417"},
+ {file = "coverage-7.13.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bff1b04cb9d4900ce5c56c4942f047dc7efe57e2608cb7c3c8936e9970ccdbee"},
+ {file = "coverage-7.13.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6ae99e4560963ad8e163e819e5d77d413d331fd00566c1e0856aa252303552c1"},
+ {file = "coverage-7.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e79a8c7d461820257d9aa43716c4efc55366d7b292e46b5b37165be1d377405d"},
+ {file = "coverage-7.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:060ee84f6a769d40c492711911a76811b4befb6fba50abb450371abb720f5bd6"},
+ {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bca209d001fd03ea2d978f8a4985093240a355c93078aee3f799852c23f561a"},
+ {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6b8092aa38d72f091db61ef83cb66076f18f02da3e1a75039a4f218629600e04"},
+ {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4a3158dc2dcce5200d91ec28cd315c999eebff355437d2765840555d765a6e5f"},
+ {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3973f353b2d70bd9796cc12f532a05945232ccae966456c8ed7034cb96bbfd6f"},
+ {file = "coverage-7.13.2-cp314-cp314t-win32.whl", hash = "sha256:79f6506a678a59d4ded048dc72f1859ebede8ec2b9a2d509ebe161f01c2879d3"},
+ {file = "coverage-7.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:196bfeabdccc5a020a57d5a368c681e3a6ceb0447d153aeccc1ab4d70a5032ba"},
+ {file = "coverage-7.13.2-cp314-cp314t-win_arm64.whl", hash = "sha256:69269ab58783e090bfbf5b916ab3d188126e22d6070bbfc93098fdd474ef937c"},
+ {file = "coverage-7.13.2-py3-none-any.whl", hash = "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5"},
+ {file = "coverage-7.13.2.tar.gz", hash = "sha256:044c6951ec37146b72a50cc81ef02217d27d4c3640efd2640311393cbbf143d3"},
+]
+
+[package.extras]
+toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
+
+[[package]]
+name = "idna"
+version = "3.11"
+description = "Internationalized Domain Names in Applications (IDNA)"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"},
+ {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"},
+]
+
+[package.extras]
+all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+description = "brain-dead simple config-ini parsing"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
+ {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
+]
+
+[[package]]
+name = "librt"
+version = "0.7.8"
+description = "Mypyc runtime library"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+markers = "platform_python_implementation != \"PyPy\""
+files = [
+ {file = "librt-0.7.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b45306a1fc5f53c9330fbee134d8b3227fe5da2ab09813b892790400aa49352d"},
+ {file = "librt-0.7.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:864c4b7083eeee250ed55135d2127b260d7eb4b5e953a9e5df09c852e327961b"},
+ {file = "librt-0.7.8-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6938cc2de153bc927ed8d71c7d2f2ae01b4e96359126c602721340eb7ce1a92d"},
+ {file = "librt-0.7.8-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66daa6ac5de4288a5bbfbe55b4caa7bf0cd26b3269c7a476ffe8ce45f837f87d"},
+ {file = "librt-0.7.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4864045f49dc9c974dadb942ac56a74cd0479a2aafa51ce272c490a82322ea3c"},
+ {file = "librt-0.7.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a36515b1328dc5b3ffce79fe204985ca8572525452eacabee2166f44bb387b2c"},
+ {file = "librt-0.7.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b7e7f140c5169798f90b80d6e607ed2ba5059784968a004107c88ad61fb3641d"},
+ {file = "librt-0.7.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff71447cb778a4f772ddc4ce360e6ba9c95527ed84a52096bd1bbf9fee2ec7c0"},
+ {file = "librt-0.7.8-cp310-cp310-win32.whl", hash = "sha256:047164e5f68b7a8ebdf9fae91a3c2161d3192418aadd61ddd3a86a56cbe3dc85"},
+ {file = "librt-0.7.8-cp310-cp310-win_amd64.whl", hash = "sha256:d6f254d096d84156a46a84861183c183d30734e52383602443292644d895047c"},
+ {file = "librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f"},
+ {file = "librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac"},
+ {file = "librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c"},
+ {file = "librt-0.7.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:631599598e2c76ded400c0a8722dec09217c89ff64dc54b060f598ed68e7d2a8"},
+ {file = "librt-0.7.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c1ba843ae20db09b9d5c80475376168feb2640ce91cd9906414f23cc267a1ff"},
+ {file = "librt-0.7.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b007bb22ea4b255d3ee39dfd06d12534de2fcc3438567d9f48cdaf67ae1ae3"},
+ {file = "librt-0.7.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbd79caaf77a3f590cbe32dc2447f718772d6eea59656a7dcb9311161b10fa75"},
+ {file = "librt-0.7.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:87808a8d1e0bd62a01cafc41f0fd6818b5a5d0ca0d8a55326a81643cdda8f873"},
+ {file = "librt-0.7.8-cp311-cp311-win32.whl", hash = "sha256:31724b93baa91512bd0a376e7cf0b59d8b631ee17923b1218a65456fa9bda2e7"},
+ {file = "librt-0.7.8-cp311-cp311-win_amd64.whl", hash = "sha256:978e8b5f13e52cf23a9e80f3286d7546baa70bc4ef35b51d97a709d0b28e537c"},
+ {file = "librt-0.7.8-cp311-cp311-win_arm64.whl", hash = "sha256:20e3946863d872f7cabf7f77c6c9d370b8b3d74333d3a32471c50d3a86c0a232"},
+ {file = "librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63"},
+ {file = "librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93"},
+ {file = "librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592"},
+ {file = "librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850"},
+ {file = "librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62"},
+ {file = "librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b"},
+ {file = "librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714"},
+ {file = "librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449"},
+ {file = "librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac"},
+ {file = "librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708"},
+ {file = "librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0"},
+ {file = "librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc"},
+ {file = "librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2"},
+ {file = "librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3"},
+ {file = "librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6"},
+ {file = "librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d"},
+ {file = "librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e"},
+ {file = "librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca"},
+ {file = "librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93"},
+ {file = "librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951"},
+ {file = "librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34"},
+ {file = "librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09"},
+ {file = "librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418"},
+ {file = "librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611"},
+ {file = "librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758"},
+ {file = "librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea"},
+ {file = "librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac"},
+ {file = "librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398"},
+ {file = "librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81"},
+ {file = "librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83"},
+ {file = "librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d"},
+ {file = "librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44"},
+ {file = "librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce"},
+ {file = "librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f"},
+ {file = "librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde"},
+ {file = "librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e"},
+ {file = "librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b"},
+ {file = "librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666"},
+ {file = "librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581"},
+ {file = "librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a"},
+ {file = "librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca"},
+ {file = "librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365"},
+ {file = "librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32"},
+ {file = "librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06"},
+ {file = "librt-0.7.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c7e8f88f79308d86d8f39c491773cbb533d6cb7fa6476f35d711076ee04fceb6"},
+ {file = "librt-0.7.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:389bd25a0db916e1d6bcb014f11aa9676cedaa485e9ec3752dfe19f196fd377b"},
+ {file = "librt-0.7.8-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73fd300f501a052f2ba52ede721232212f3b06503fa12665408ecfc9d8fd149c"},
+ {file = "librt-0.7.8-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d772edc6a5f7835635c7562f6688e031f0b97e31d538412a852c49c9a6c92d5"},
+ {file = "librt-0.7.8-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde8a130bd0f239e45503ab39fab239ace094d63ee1d6b67c25a63d741c0f71"},
+ {file = "librt-0.7.8-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fdec6e2368ae4f796fc72fad7fd4bd1753715187e6d870932b0904609e7c878e"},
+ {file = "librt-0.7.8-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:00105e7d541a8f2ee5be52caacea98a005e0478cfe78c8080fbb7b5d2b340c63"},
+ {file = "librt-0.7.8-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c6f8947d3dfd7f91066c5b4385812c18be26c9d5a99ca56667547f2c39149d94"},
+ {file = "librt-0.7.8-cp39-cp39-win32.whl", hash = "sha256:41d7bb1e07916aeb12ae4a44e3025db3691c4149ab788d0315781b4d29b86afb"},
+ {file = "librt-0.7.8-cp39-cp39-win_amd64.whl", hash = "sha256:e90a8e237753c83b8e484d478d9a996dc5e39fd5bd4c6ce32563bc8123f132be"},
+ {file = "librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862"},
+]
+
+[[package]]
+name = "loguru"
+version = "0.7.3"
+description = "Python logging made (stupidly) simple"
+optional = false
+python-versions = "<4.0,>=3.5"
+groups = ["main"]
+files = [
+ {file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"},
+ {file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"},
+]
+
+[package.dependencies]
+colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""}
+win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""}
+
+[package.extras]
+dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==v0.910) ; python_version < \"3.6\"", "mypy (==v0.971) ; python_version == \"3.6\"", "mypy (==v1.13.0) ; python_version >= \"3.8\"", "mypy (==v1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""]
+
+[[package]]
+name = "mypy"
+version = "1.19.1"
+description = "Optional static typing for Python"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec"},
+ {file = "mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b"},
+ {file = "mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6"},
+ {file = "mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74"},
+ {file = "mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1"},
+ {file = "mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac"},
+ {file = "mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288"},
+ {file = "mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab"},
+ {file = "mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6"},
+ {file = "mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331"},
+ {file = "mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925"},
+ {file = "mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042"},
+ {file = "mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1"},
+ {file = "mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e"},
+ {file = "mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2"},
+ {file = "mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8"},
+ {file = "mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a"},
+ {file = "mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13"},
+ {file = "mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250"},
+ {file = "mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b"},
+ {file = "mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e"},
+ {file = "mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef"},
+ {file = "mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75"},
+ {file = "mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd"},
+ {file = "mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1"},
+ {file = "mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718"},
+ {file = "mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b"},
+ {file = "mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045"},
+ {file = "mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957"},
+ {file = "mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f"},
+ {file = "mypy-1.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3"},
+ {file = "mypy-1.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a"},
+ {file = "mypy-1.19.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67"},
+ {file = "mypy-1.19.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e"},
+ {file = "mypy-1.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376"},
+ {file = "mypy-1.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24"},
+ {file = "mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247"},
+ {file = "mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba"},
+]
+
+[package.dependencies]
+librt = {version = ">=0.6.2", markers = "platform_python_implementation != \"PyPy\""}
+mypy_extensions = ">=1.0.0"
+pathspec = ">=0.9.0"
+typing_extensions = ">=4.6.0"
+
+[package.extras]
+dmypy = ["psutil (>=4.0)"]
+faster-cache = ["orjson"]
+install-types = ["pip"]
+mypyc = ["setuptools (>=50)"]
+reports = ["lxml"]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+description = "Type system extensions for programs checked with the mypy type checker."
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
+ {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
+]
+
+[[package]]
+name = "outcome"
+version = "1.3.0.post0"
+description = "Capture the outcome of Python function calls."
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"},
+ {file = "outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8"},
+]
+
+[package.dependencies]
+attrs = ">=19.2.0"
+
+[[package]]
+name = "packaging"
+version = "26.0"
+description = "Core utilities for Python packages"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"},
+ {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"},
+]
+
+[[package]]
+name = "pathspec"
+version = "1.0.4"
+description = "Utility library for gitignore style pattern matching of file paths."
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"},
+ {file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"},
+]
+
+[package.extras]
+hyperscan = ["hyperscan (>=0.7)"]
+optional = ["typing-extensions (>=4)"]
+re2 = ["google-re2 (>=1.1)"]
+tests = ["pytest (>=9)", "typing-extensions (>=4.15)"]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+description = "plugin and hook calling mechanisms for python"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
+ {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
+]
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["coverage", "pytest", "pytest-benchmark"]
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+description = "C parser in Python"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+markers = "os_name == \"nt\" and implementation_name != \"pypy\" and implementation_name != \"PyPy\""
+files = [
+ {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"},
+ {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"},
+]
+
+[[package]]
+name = "pyfuse3"
+version = "3.4.2"
+description = "Python 3 bindings for libfuse 3 with async I/O support"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "pyfuse3-3.4.2.tar.gz", hash = "sha256:0a59031969c4ba51a5ec1b67f3c5c24f641a6a3f8119a86edad56debcb9084d9"},
+]
+
+[package.dependencies]
+trio = ">=0.15"
+
+[[package]]
+name = "pygments"
+version = "2.19.2"
+description = "Pygments is a syntax highlighting package written in Python."
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
+ {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
+]
+
+[package.extras]
+windows-terminal = ["colorama (>=0.4.6)"]
+
+[[package]]
+name = "pyside6"
+version = "6.10.1"
+description = "Python bindings for the Qt cross-platform application and UI framework"
+optional = false
+python-versions = "<3.15,>=3.9"
+groups = ["main"]
+files = [
+ {file = "pyside6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:d0e70dd0e126d01986f357c2a555722f9462cf8a942bf2ce180baf69f468e516"},
+ {file = "pyside6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4053bf51ba2c2cb20e1005edd469997976a02cec009f7c46356a0b65c137f1fa"},
+ {file = "pyside6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:7d3ca20a40139ca5324a7864f1d91cdf2ff237e11bd16354a42670f2a4eeb13c"},
+ {file = "pyside6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9f89ff994f774420eaa38cec6422fddd5356611d8481774820befd6f3bb84c9e"},
+ {file = "pyside6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:9c5c1d94387d1a32a6fae25348097918ef413b87dfa3767c46f737c6d48ae437"},
+]
+
+[package.dependencies]
+PySide6_Addons = "6.10.1"
+PySide6_Essentials = "6.10.1"
+shiboken6 = "6.10.1"
+
+[[package]]
+name = "pyside6-addons"
+version = "6.10.1"
+description = "Python bindings for the Qt cross-platform application and UI framework (Addons)"
+optional = false
+python-versions = "<3.15,>=3.9"
+groups = ["main"]
+files = [
+ {file = "pyside6_addons-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:4d2b82bbf9b861134845803837011e5f9ac7d33661b216805273cf0c6d0f8e82"},
+ {file = "pyside6_addons-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:330c229b58d30083a7b99ed22e118eb4f4126408429816a4044ccd0438ae81b4"},
+ {file = "pyside6_addons-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:56864b5fecd6924187a2d0f7e98d968ed72b6cc267caa5b294cd7e88fff4e54c"},
+ {file = "pyside6_addons-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:b6e249d15407dd33d6a2ffabd9dc6d7a8ab8c95d05f16a71dad4d07781c76341"},
+ {file = "pyside6_addons-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:0de303c0447326cdc6c8be5ab066ef581e2d0baf22560c9362d41b8304fdf2db"},
+]
+
+[package.dependencies]
+PySide6_Essentials = "6.10.1"
+shiboken6 = "6.10.1"
+
+[[package]]
+name = "pyside6-essentials"
+version = "6.10.1"
+description = "Python bindings for the Qt cross-platform application and UI framework (Essentials)"
+optional = false
+python-versions = "<3.15,>=3.9"
+groups = ["main"]
+files = [
+ {file = "pyside6_essentials-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:cd224aff3bb26ff1fca32c050e1c4d0bd9f951a96219d40d5f3d0128485b0bbe"},
+ {file = "pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e9ccbfb58c03911a0bce1f2198605b02d4b5ca6276bfc0cbcf7c6f6393ffb856"},
+ {file = "pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:ec8617c9b143b0c19ba1cc5a7e98c538e4143795480cb152aee47802c18dc5d2"},
+ {file = "pyside6_essentials-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9555a48e8f0acf63fc6a23c250808db841b28a66ed6ad89ee0e4df7628752674"},
+ {file = "pyside6_essentials-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:4d1d248644f1778f8ddae5da714ca0f5a150a5e6f602af2765a7d21b876da05c"},
+]
+
+[package.dependencies]
+shiboken6 = "6.10.1"
+
+[[package]]
+name = "pytest"
+version = "9.0.2"
+description = "pytest: simple powerful testing with Python"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"},
+ {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"},
+]
+
+[package.dependencies]
+colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
+iniconfig = ">=1.0.1"
+packaging = ">=22"
+pluggy = ">=1.5,<2"
+pygments = ">=2.7.2"
+
+[package.extras]
+dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
+
+[[package]]
+name = "pytest-cov"
+version = "7.0.0"
+description = "Pytest plugin for measuring coverage."
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"},
+ {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"},
+]
+
+[package.dependencies]
+coverage = {version = ">=7.10.6", extras = ["toml"]}
+pluggy = ">=1.2"
+pytest = ">=7"
+
+[package.extras]
+testing = ["process-tests", "pytest-xdist", "virtualenv"]
+
+[[package]]
+name = "python-dotenv"
+version = "1.2.1"
+description = "Read key-value pairs from a .env file and set them as environment variables"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61"},
+ {file = "python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6"},
+]
+
+[package.extras]
+cli = ["click (>=5.0)"]
+
+[[package]]
+name = "ruff"
+version = "0.14.14"
+description = "An extremely fast Python linter and code formatter, written in Rust."
+optional = false
+python-versions = ">=3.7"
+groups = ["dev"]
+files = [
+ {file = "ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed"},
+ {file = "ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c"},
+ {file = "ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de"},
+ {file = "ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e"},
+ {file = "ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8"},
+ {file = "ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906"},
+ {file = "ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480"},
+ {file = "ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df"},
+ {file = "ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b"},
+ {file = "ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974"},
+ {file = "ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66"},
+ {file = "ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13"},
+ {file = "ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412"},
+ {file = "ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3"},
+ {file = "ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b"},
+ {file = "ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167"},
+ {file = "ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd"},
+ {file = "ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c"},
+ {file = "ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b"},
+]
+
+[[package]]
+name = "shiboken6"
+version = "6.10.1"
+description = "Python/C++ bindings helper module"
+optional = false
+python-versions = "<3.15,>=3.9"
+groups = ["main"]
+files = [
+ {file = "shiboken6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:9f2990f5b61b0b68ecadcd896ab4441f2cb097eef7797ecc40584107d9850d71"},
+ {file = "shiboken6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4221a52dfb81f24a0d20cc4f8981cb6edd810d5a9fb28287ce10d342573a0e4"},
+ {file = "shiboken6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:c095b00f4d6bf578c0b2464bb4e264b351a99345374478570f69e2e679a2a1d0"},
+ {file = "shiboken6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:c1601d3cda1fa32779b141663873741b54e797cb0328458d7466281f117b0a4e"},
+ {file = "shiboken6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:5cf800917008587b551005a45add2d485cca66f5f7ecd5b320e9954e40448cc9"},
+]
+
+[[package]]
+name = "sniffio"
+version = "1.3.1"
+description = "Sniff out which async library your code is running under"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
+ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
+]
+
+[[package]]
+name = "sortedcontainers"
+version = "2.4.0"
+description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"},
+ {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"},
+]
+
+[[package]]
+name = "trio"
+version = "0.32.0"
+description = "A friendly Python library for async concurrency and I/O"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "trio-0.32.0-py3-none-any.whl", hash = "sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5"},
+ {file = "trio-0.32.0.tar.gz", hash = "sha256:150f29ec923bcd51231e1d4c71c7006e65247d68759dd1c19af4ea815a25806b"},
+]
+
+[package.dependencies]
+attrs = ">=23.2.0"
+cffi = {version = ">=1.14", markers = "os_name == \"nt\" and implementation_name != \"pypy\""}
+idna = "*"
+outcome = "*"
+sniffio = ">=1.3.0"
+sortedcontainers = "*"
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+description = "Backported and Experimental Type Hints for Python 3.9+"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
+ {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
+]
+
+[[package]]
+name = "watchdog"
+version = "6.0.0"
+description = "Filesystem events monitoring"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26"},
+ {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112"},
+ {file = "watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3"},
+ {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c"},
+ {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2"},
+ {file = "watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c"},
+ {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948"},
+ {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860"},
+ {file = "watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0"},
+ {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c"},
+ {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134"},
+ {file = "watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b"},
+ {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8"},
+ {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a"},
+ {file = "watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c"},
+ {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881"},
+ {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11"},
+ {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa"},
+ {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e"},
+ {file = "watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13"},
+ {file = "watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379"},
+ {file = "watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e"},
+ {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f"},
+ {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26"},
+ {file = "watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c"},
+ {file = "watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2"},
+ {file = "watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a"},
+ {file = "watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680"},
+ {file = "watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f"},
+ {file = "watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282"},
+]
+
+[package.extras]
+watchmedo = ["PyYAML (>=3.10)"]
+
+[[package]]
+name = "win32-setctime"
+version = "1.2.0"
+description = "A small Python utility to set file creation time on Windows"
+optional = false
+python-versions = ">=3.5"
+groups = ["main"]
+markers = "sys_platform == \"win32\""
+files = [
+ {file = "win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390"},
+ {file = "win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0"},
+]
+
+[package.extras]
+dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"]
+
+[metadata]
+lock-version = "2.1"
+python-versions = ">=3.13,<3.15"
+content-hash = "56774f975c6ce4dcb62d6d3fa2ab6ef6fddf89ef256e09541dce1c34bce30c69"
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..e8ad5db
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,35 @@
+[project]
+name = "vault"
+version = "1.0.0"
+description = ""
+authors = [
+ {name = "Jan Doubravský",email = "jan.doubravsky@gmail.com"}
+]
+readme = "README.md"
+requires-python = ">=3.13,<3.15"
+dependencies = [
+ "pyside6 (>=6.10.1,<7.0.0)",
+ "pyfuse3 (>=3.4.2,<4.0.0)",
+ "watchdog (>=6.0.0,<7.0.0)",
+ "loguru (>=0.7.3,<0.8.0)",
+ "python-dotenv (>=1.2.1,<2.0.0)"
+]
+
+[tool.poetry]
+package-mode = false
+
+
+[tool.poetry.group.dev.dependencies]
+pytest = "^9.0.2"
+pytest-cov = "^7.0.0"
+ruff = "^0.14.14"
+mypy = "^1.19.1"
+
+[tool.pytest.ini_options]
+markers = [
+ "integration: marks tests as integration tests (require udisks2, actual mounting)",
+]
+
+[build-system]
+requires = ["poetry-core>=2.0.0,<3.0.0"]
+build-backend = "poetry.core.masonry.api"
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/core/__init__.py b/src/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/core/container.py b/src/core/container.py
new file mode 100644
index 0000000..220437a
--- /dev/null
+++ b/src/core/container.py
@@ -0,0 +1,236 @@
+"""Container management for mounting/unmounting vault images."""
+
+import subprocess
+from pathlib import Path
+
+from loguru import logger
+
+
+class ContainerError(Exception):
+ """Raised when container operation fails."""
+
+
+class Container:
+ """Manages mounting and unmounting of a vault image.
+
+ Uses udisksctl for user-space mounting (no root required).
+
+ Attributes:
+ image_path: Path to the .vault image file
+ mount_point: Path where the image is mounted (None if not mounted)
+ loop_device: Loop device path (e.g., /dev/loop0)
+ """
+
+ def __init__(self, image_path: Path) -> None:
+ """Initialize container.
+
+ Args:
+ image_path: Path to the .vault image file
+ """
+ self.image_path = image_path
+ self.mount_point: Path | None = None
+ self.loop_device: str | None = None
+ self._object_path: str | None = None # udisks object path
+
+ def mount(self, mount_point: Path | None = None) -> Path:
+ """Mount the vault image.
+
+ Args:
+ mount_point: Optional custom mount point. If None, uses
+ udisks default (/run/media/$USER/VAULT)
+
+ Returns:
+ Path where the image is mounted
+
+ Raises:
+ ContainerError: If mounting fails
+ """
+ if self.is_mounted():
+ raise ContainerError("Container is already mounted")
+
+ if not self.image_path.exists():
+ raise ContainerError(f"Image not found: {self.image_path}")
+
+ logger.info(f"Mounting: {self.image_path}")
+
+ try:
+ # Set up loop device using udisksctl
+ self._setup_loop_device()
+
+ # Mount the loop device
+ self._mount_loop_device(mount_point)
+
+ logger.info(f"Mounted at: {self.mount_point}")
+ return self.mount_point # type: ignore
+
+ except Exception as e:
+ # Cleanup on failure
+ self._cleanup()
+ raise ContainerError(f"Failed to mount: {e}") from e
+
+ def _setup_loop_device(self) -> None:
+ """Set up loop device using udisksctl."""
+ logger.debug(f"Setting up loop device for: {self.image_path}")
+
+ try:
+ result = subprocess.run(
+ [
+ "udisksctl",
+ "loop-setup",
+ "--file",
+ str(self.image_path),
+ "--no-user-interaction",
+ ],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+
+ # Parse output: "Mapped file /path/to/file as /dev/loop0."
+ output = result.stdout.strip()
+ logger.debug(f"udisksctl loop-setup output: {output}")
+
+ # Extract loop device path
+ if "as" in output:
+ self.loop_device = output.split("as")[-1].strip().rstrip(".")
+ # Get the object path for later unmounting
+ self._object_path = f"/org/freedesktop/UDisks2/block_devices/{Path(self.loop_device).name}"
+ else:
+ raise ContainerError(f"Unexpected udisksctl output: {output}")
+
+ except subprocess.CalledProcessError as e:
+ raise ContainerError(f"udisksctl loop-setup failed: {e.stderr}") from e
+ except FileNotFoundError:
+ raise ContainerError(
+ "udisksctl not found. Install with: sudo apt install udisks2"
+ )
+
+ def _mount_loop_device(self, custom_mount_point: Path | None) -> None:
+ """Mount the loop device."""
+ if not self.loop_device:
+ raise ContainerError("No loop device set up")
+
+ logger.debug(f"Mounting loop device: {self.loop_device}")
+
+ try:
+ cmd = [
+ "udisksctl",
+ "mount",
+ "--block-device",
+ self.loop_device,
+ "--no-user-interaction",
+ ]
+
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+
+ # Parse output: "Mounted /dev/loop0 at /run/media/user/VAULT"
+ output = result.stdout.strip()
+ logger.debug(f"udisksctl mount output: {output}")
+
+ if "at" in output:
+ mount_path = output.split("at")[-1].strip().rstrip(".")
+ self.mount_point = Path(mount_path)
+ else:
+ raise ContainerError(f"Unexpected udisksctl mount output: {output}")
+
+ except subprocess.CalledProcessError as e:
+ raise ContainerError(f"udisksctl mount failed: {e.stderr}") from e
+
+ def unmount(self) -> None:
+ """Unmount the vault image.
+
+ Raises:
+ ContainerError: If unmounting fails
+ """
+ if not self.is_mounted():
+ logger.warning("Container is not mounted")
+ return
+
+ logger.info(f"Unmounting: {self.mount_point}")
+
+ try:
+ self._unmount_loop_device()
+ self._remove_loop_device()
+ logger.info("Unmounted successfully")
+
+ except Exception as e:
+ raise ContainerError(f"Failed to unmount: {e}") from e
+
+ finally:
+ self._cleanup()
+
+ def _unmount_loop_device(self) -> None:
+ """Unmount the loop device."""
+ if not self.loop_device:
+ return
+
+ logger.debug(f"Unmounting: {self.loop_device}")
+
+ try:
+ subprocess.run(
+ [
+ "udisksctl",
+ "unmount",
+ "--block-device",
+ self.loop_device,
+ "--no-user-interaction",
+ ],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ except subprocess.CalledProcessError as e:
+ raise ContainerError(f"udisksctl unmount failed: {e.stderr}") from e
+
+ def _remove_loop_device(self) -> None:
+ """Remove the loop device."""
+ if not self.loop_device:
+ return
+
+ logger.debug(f"Removing loop device: {self.loop_device}")
+
+ try:
+ subprocess.run(
+ [
+ "udisksctl",
+ "loop-delete",
+ "--block-device",
+ self.loop_device,
+ "--no-user-interaction",
+ ],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ except subprocess.CalledProcessError as e:
+ # Loop device might already be removed
+ logger.warning(f"Failed to remove loop device: {e.stderr}")
+
+ def _cleanup(self) -> None:
+ """Reset internal state."""
+ self.mount_point = None
+ self.loop_device = None
+ self._object_path = None
+
+ def is_mounted(self) -> bool:
+ """Check if the container is currently mounted.
+
+ Returns:
+ True if mounted, False otherwise
+ """
+ return self.mount_point is not None and self.mount_point.exists()
+
+ def __enter__(self) -> "Container":
+ """Context manager entry - mount the container."""
+ self.mount()
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+ """Context manager exit - unmount the container."""
+ if self.is_mounted():
+ self.unmount()
diff --git a/src/core/file_entry.py b/src/core/file_entry.py
new file mode 100644
index 0000000..7d43438
--- /dev/null
+++ b/src/core/file_entry.py
@@ -0,0 +1,86 @@
+"""File entry dataclass for representing files in the vault."""
+
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+
+
+@dataclass(frozen=True)
+class FileEntry:
+ """Immutable representation of a file in the vault.
+
+ Attributes:
+ path: Relative path within the vault (e.g., 'documents/file.txt')
+ hash: SHA-256 hash of the file content (e.g., 'sha256:abc123...')
+ size: File size in bytes
+ created: Creation timestamp (ISO format)
+ modified: Last modification timestamp (ISO format)
+ """
+
+ path: str
+ hash: str
+ size: int
+ created: datetime
+ modified: datetime
+
+ def to_dict(self) -> dict:
+ """Convert to dictionary for JSON serialization."""
+ return {
+ "path": self.path,
+ "hash": self.hash,
+ "size": self.size,
+ "created": self.created.isoformat(),
+ "modified": self.modified.isoformat(),
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "FileEntry":
+ """Create FileEntry from dictionary (JSON deserialization)."""
+ return cls(
+ path=data["path"],
+ hash=data["hash"],
+ size=data["size"],
+ created=datetime.fromisoformat(data["created"]),
+ modified=datetime.fromisoformat(data["modified"]),
+ )
+
+ @classmethod
+ def from_path(cls, base_path: Path, file_path: Path) -> "FileEntry":
+ """Create FileEntry from actual file on disk.
+
+ Args:
+ base_path: Base path of the vault (mount point)
+ file_path: Absolute path to the file
+
+ Returns:
+ FileEntry with computed hash and timestamps
+ """
+ import hashlib
+
+ relative_path = file_path.relative_to(base_path)
+ stat = file_path.stat()
+
+ # Compute SHA-256 hash
+ sha256 = hashlib.sha256()
+ with open(file_path, "rb") as f:
+ for chunk in iter(lambda: f.read(1024 * 1024), b""):
+ sha256.update(chunk)
+
+ return cls(
+ path=str(relative_path),
+ hash=f"sha256:{sha256.hexdigest()}",
+ size=stat.st_size,
+ created=datetime.fromtimestamp(stat.st_ctime),
+ modified=datetime.fromtimestamp(stat.st_mtime),
+ )
+
+ def has_changed(self, other: "FileEntry") -> bool:
+ """Check if file has changed compared to another entry.
+
+ Compares hash for content change detection.
+ """
+ return self.hash != other.hash
+
+ def is_newer_than(self, other: "FileEntry") -> bool:
+ """Check if this file is newer than another entry."""
+ return self.modified > other.modified
diff --git a/src/core/file_sync.py b/src/core/file_sync.py
new file mode 100644
index 0000000..8c131c5
--- /dev/null
+++ b/src/core/file_sync.py
@@ -0,0 +1,238 @@
+"""File synchronization module for copying files between replicas.
+
+Provides chunked copy with progress callbacks for large files.
+"""
+
+import shutil
+from collections.abc import Callable
+from dataclasses import dataclass
+from pathlib import Path
+
+from loguru import logger
+
+# Default chunk size: 1 MB
+DEFAULT_CHUNK_SIZE = 1024 * 1024
+
+
+@dataclass
+class CopyProgress:
+ """Progress information for file copy operation."""
+
+ src_path: Path
+ dst_path: Path
+ bytes_copied: int
+ total_bytes: int
+
+ @property
+ def percent(self) -> float:
+ """Return progress as percentage (0-100)."""
+ if self.total_bytes == 0:
+ return 100.0
+ return (self.bytes_copied / self.total_bytes) * 100
+
+ @property
+ def is_complete(self) -> bool:
+ """Check if copy is complete."""
+ return self.bytes_copied >= self.total_bytes
+
+
+# Type alias for progress callback
+ProgressCallback = Callable[[CopyProgress], None]
+
+
+def copy_file_with_progress(
+ src: Path,
+ dst: Path,
+ callback: ProgressCallback | None = None,
+ chunk_size: int = DEFAULT_CHUNK_SIZE,
+) -> None:
+ """Copy a file with progress callback.
+
+ Args:
+ src: Source file path
+ dst: Destination file path
+ callback: Optional callback called after each chunk
+ chunk_size: Size of chunks to read/write (default 1MB)
+
+ Raises:
+ FileNotFoundError: If source file doesn't exist
+ IsADirectoryError: If source or destination is a directory
+ PermissionError: If permission denied
+ """
+ if not src.exists():
+ raise FileNotFoundError(f"Source file not found: {src}")
+
+ if src.is_dir():
+ raise IsADirectoryError(f"Source is a directory: {src}")
+
+ if dst.exists() and dst.is_dir():
+ raise IsADirectoryError(f"Destination is a directory: {dst}")
+
+ # Create parent directories if needed
+ dst.parent.mkdir(parents=True, exist_ok=True)
+
+ total_bytes = src.stat().st_size
+ bytes_copied = 0
+
+ logger.debug(f"Copying {src} -> {dst} ({total_bytes} bytes)")
+
+ with open(src, "rb") as fsrc, open(dst, "wb") as fdst:
+ while chunk := fsrc.read(chunk_size):
+ fdst.write(chunk)
+ bytes_copied += len(chunk)
+
+ if callback:
+ progress = CopyProgress(
+ src_path=src,
+ dst_path=dst,
+ bytes_copied=bytes_copied,
+ total_bytes=total_bytes,
+ )
+ callback(progress)
+
+ # Preserve file metadata (timestamps, permissions)
+ shutil.copystat(src, dst)
+
+ logger.debug(f"Copy complete: {src} -> {dst}")
+
+
+def copy_directory_with_progress(
+ src: Path,
+ dst: Path,
+ callback: ProgressCallback | None = None,
+ chunk_size: int = DEFAULT_CHUNK_SIZE,
+) -> None:
+ """Recursively copy a directory with progress callback.
+
+ Args:
+ src: Source directory path
+ dst: Destination directory path
+ callback: Optional callback called after each chunk of each file
+ chunk_size: Size of chunks to read/write (default 1MB)
+
+ Raises:
+ FileNotFoundError: If source directory doesn't exist
+ NotADirectoryError: If source is not a directory
+ """
+ if not src.exists():
+ raise FileNotFoundError(f"Source directory not found: {src}")
+
+ if not src.is_dir():
+ raise NotADirectoryError(f"Source is not a directory: {src}")
+
+ # Create destination directory
+ dst.mkdir(parents=True, exist_ok=True)
+
+ # Copy directory metadata
+ shutil.copystat(src, dst)
+
+ for item in src.iterdir():
+ src_item = item
+ dst_item = dst / item.name
+
+ if item.is_dir():
+ copy_directory_with_progress(src_item, dst_item, callback, chunk_size)
+ else:
+ copy_file_with_progress(src_item, dst_item, callback, chunk_size)
+
+
+def delete_file(path: Path) -> None:
+ """Delete a file.
+
+ Args:
+ path: File path to delete
+
+ Raises:
+ FileNotFoundError: If file doesn't exist
+ IsADirectoryError: If path is a directory
+ """
+ if not path.exists():
+ raise FileNotFoundError(f"File not found: {path}")
+
+ if path.is_dir():
+ raise IsADirectoryError(f"Path is a directory, use delete_directory: {path}")
+
+ path.unlink()
+ logger.debug(f"Deleted file: {path}")
+
+
+def delete_directory(path: Path) -> None:
+ """Recursively delete a directory.
+
+ Args:
+ path: Directory path to delete
+
+ Raises:
+ FileNotFoundError: If directory doesn't exist
+ NotADirectoryError: If path is not a directory
+ """
+ if not path.exists():
+ raise FileNotFoundError(f"Directory not found: {path}")
+
+ if not path.is_dir():
+ raise NotADirectoryError(f"Path is not a directory: {path}")
+
+ shutil.rmtree(path)
+ logger.debug(f"Deleted directory: {path}")
+
+
+def move_file(src: Path, dst: Path) -> None:
+ """Move/rename a file.
+
+ Args:
+ src: Source file path
+ dst: Destination file path
+
+ Raises:
+ FileNotFoundError: If source doesn't exist
+ """
+ if not src.exists():
+ raise FileNotFoundError(f"Source not found: {src}")
+
+ # Create parent directories if needed
+ dst.parent.mkdir(parents=True, exist_ok=True)
+
+ src.rename(dst)
+ logger.debug(f"Moved: {src} -> {dst}")
+
+
+def sync_file(
+ src: Path,
+ dst: Path,
+ callback: ProgressCallback | None = None,
+ chunk_size: int = DEFAULT_CHUNK_SIZE,
+) -> bool:
+ """Synchronize a single file from source to destination.
+
+ Only copies if source is newer or destination doesn't exist.
+
+ Args:
+ src: Source file path
+ dst: Destination file path
+ callback: Optional progress callback
+ chunk_size: Chunk size for copying
+
+ Returns:
+ True if file was copied, False if already up to date
+
+ Raises:
+ FileNotFoundError: If source doesn't exist
+ """
+ if not src.exists():
+ raise FileNotFoundError(f"Source file not found: {src}")
+
+ # Always copy if destination doesn't exist
+ if not dst.exists():
+ copy_file_with_progress(src, dst, callback, chunk_size)
+ return True
+
+ # Compare modification times
+ src_mtime = src.stat().st_mtime
+ dst_mtime = dst.stat().st_mtime
+
+ if src_mtime > dst_mtime:
+ copy_file_with_progress(src, dst, callback, chunk_size)
+ return True
+
+ logger.debug(f"File already up to date: {dst}")
+ return False
diff --git a/src/core/file_watcher.py b/src/core/file_watcher.py
new file mode 100644
index 0000000..d0486e7
--- /dev/null
+++ b/src/core/file_watcher.py
@@ -0,0 +1,222 @@
+"""File watcher module for detecting changes in vault mount point.
+
+Uses watchdog library with inotify backend on Linux.
+"""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from enum import Enum
+from pathlib import Path
+from typing import Any
+
+from loguru import logger
+from watchdog.events import (
+ DirCreatedEvent,
+ DirDeletedEvent,
+ DirMovedEvent,
+ FileClosedEvent,
+ FileCreatedEvent,
+ FileDeletedEvent,
+ FileModifiedEvent,
+ FileMovedEvent,
+ FileSystemEvent,
+ FileSystemEventHandler,
+)
+from watchdog.observers import Observer
+
+
+class EventType(Enum):
+ """Types of file system events."""
+
+ CREATED = "created"
+ MODIFIED = "modified"
+ DELETED = "deleted"
+ MOVED = "moved"
+
+
+@dataclass(frozen=True)
+class FileEvent:
+ """Represents a file system event."""
+
+ event_type: EventType
+ path: str
+ is_directory: bool
+ dest_path: str | None = None # Only for MOVED events
+
+ def __str__(self) -> str:
+ if self.event_type == EventType.MOVED:
+ return f"{self.event_type.value}: {self.path} -> {self.dest_path}"
+ return f"{self.event_type.value}: {self.path}"
+
+
+# Type alias for file event callbacks
+FileEventCallback = Callable[[FileEvent], None]
+
+
+def _ensure_str(path: str | bytes) -> str:
+ """Convert bytes path to str if necessary."""
+ if isinstance(path, bytes):
+ return path.decode("utf-8", errors="replace")
+ return path
+
+
+class VaultEventHandler(FileSystemEventHandler):
+ """Handles file system events and converts them to FileEvent objects."""
+
+ def __init__(
+ self,
+ base_path: Path,
+ callback: FileEventCallback,
+ ignore_patterns: list[str] | None = None,
+ ) -> None:
+ super().__init__()
+ self.base_path = base_path
+ self.callback = callback
+ self.ignore_patterns = ignore_patterns or [".vault"]
+
+ def _should_ignore(self, path: str) -> bool:
+ """Check if path should be ignored."""
+ rel_path = Path(path).relative_to(self.base_path)
+ for pattern in self.ignore_patterns:
+ if pattern in rel_path.parts:
+ return True
+ return False
+
+ def _get_relative_path(self, path: str) -> str:
+ """Convert absolute path to relative path from base."""
+ return str(Path(path).relative_to(self.base_path))
+
+ def _emit_event(
+ self,
+ event_type: EventType,
+ src_path: str,
+ is_directory: bool,
+ dest_path: str | None = None,
+ ) -> None:
+ """Create and emit a FileEvent."""
+ if self._should_ignore(src_path):
+ return
+
+ rel_path = self._get_relative_path(src_path)
+ rel_dest = self._get_relative_path(dest_path) if dest_path else None
+
+ file_event = FileEvent(
+ event_type=event_type,
+ path=rel_path,
+ is_directory=is_directory,
+ dest_path=rel_dest,
+ )
+
+ logger.debug(f"File event: {file_event}")
+ self.callback(file_event)
+
+ def on_created(self, event: FileSystemEvent) -> None:
+ """Handle file/directory creation."""
+ if isinstance(event, (FileCreatedEvent, DirCreatedEvent)):
+ self._emit_event(
+ EventType.CREATED,
+ _ensure_str(event.src_path),
+ event.is_directory,
+ )
+
+ def on_modified(self, event: FileSystemEvent) -> None:
+ """Handle file modification."""
+ # Only track file modifications, not directory modifications
+ if isinstance(event, FileModifiedEvent) and not event.is_directory:
+ self._emit_event(
+ EventType.MODIFIED,
+ _ensure_str(event.src_path),
+ is_directory=False,
+ )
+
+ def on_deleted(self, event: FileSystemEvent) -> None:
+ """Handle file/directory deletion."""
+ if isinstance(event, (FileDeletedEvent, DirDeletedEvent)):
+ self._emit_event(
+ EventType.DELETED,
+ _ensure_str(event.src_path),
+ event.is_directory,
+ )
+
+ def on_moved(self, event: FileSystemEvent) -> None:
+ """Handle file/directory move/rename."""
+ if isinstance(event, (FileMovedEvent, DirMovedEvent)):
+ self._emit_event(
+ EventType.MOVED,
+ _ensure_str(event.src_path),
+ event.is_directory,
+ dest_path=_ensure_str(event.dest_path),
+ )
+
+ def on_closed(self, event: FileSystemEvent) -> None:
+ """Handle file close - emit as modified for write operations."""
+ # FileClosedEvent indicates a file was closed after writing
+ # This is more reliable than on_modified for detecting actual saves
+ if isinstance(event, FileClosedEvent) and not event.is_directory:
+ # We emit MODIFIED because the file content was finalized
+ self._emit_event(
+ EventType.MODIFIED,
+ _ensure_str(event.src_path),
+ is_directory=False,
+ )
+
+
+class FileWatcher:
+ """Watches a directory for file system changes."""
+
+ def __init__(
+ self,
+ watch_path: Path,
+ callback: FileEventCallback,
+ ignore_patterns: list[str] | None = None,
+ ) -> None:
+ self.watch_path = watch_path
+ self.callback = callback
+ self.ignore_patterns = ignore_patterns or [".vault"]
+ self._observer: Any = None
+ self._running = False
+
+ def start(self) -> None:
+ """Start watching for file system events."""
+ if self._running:
+ logger.warning("FileWatcher already running")
+ return
+
+ if not self.watch_path.exists():
+ raise FileNotFoundError(f"Watch path does not exist: {self.watch_path}")
+
+ handler = VaultEventHandler(
+ base_path=self.watch_path,
+ callback=self.callback,
+ ignore_patterns=self.ignore_patterns,
+ )
+
+ self._observer = Observer()
+ self._observer.schedule(handler, str(self.watch_path), recursive=True)
+ self._observer.start()
+ self._running = True
+
+ logger.info(f"FileWatcher started for: {self.watch_path}")
+
+ def stop(self) -> None:
+ """Stop watching for file system events."""
+ if not self._running or self._observer is None:
+ return
+
+ self._observer.stop()
+ self._observer.join(timeout=5.0)
+ self._observer = None
+ self._running = False
+
+ logger.info("FileWatcher stopped")
+
+ def is_running(self) -> bool:
+ """Check if watcher is currently running."""
+ return self._running
+
+ def __enter__(self) -> "FileWatcher":
+ self.start()
+ return self
+
+ def __exit__(self, exc_type: type | None, exc_val: Exception | None, exc_tb: object) -> None:
+ self.stop()
diff --git a/src/core/image_manager.py b/src/core/image_manager.py
new file mode 100644
index 0000000..9dcb07f
--- /dev/null
+++ b/src/core/image_manager.py
@@ -0,0 +1,202 @@
+"""Image manager for creating and resizing vault disk images."""
+
+import subprocess
+from pathlib import Path
+
+from loguru import logger
+
+
+class ImageError(Exception):
+ """Raised when image operation fails."""
+
+
+def create_sparse_image(path: Path, size_mb: int) -> None:
+ """Create a sparse disk image with exFAT filesystem.
+
+ Args:
+ path: Path where to create the .vault file
+ size_mb: Size of the image in megabytes
+
+ Raises:
+ ImageError: If creation fails
+ """
+ logger.info(f"Creating sparse image: {path} ({size_mb} MB)")
+
+ if path.exists():
+ raise ImageError(f"File already exists: {path}")
+
+ try:
+ # Create sparse file
+ with open(path, "wb") as f:
+ f.seek(size_mb * 1024 * 1024 - 1)
+ f.write(b"\0")
+
+ logger.debug(f"Sparse file created: {path}")
+
+ # Format as exFAT
+ _format_exfat(path)
+
+ logger.info(f"Image created successfully: {path}")
+
+ except Exception as e:
+ # Cleanup on failure
+ if path.exists():
+ path.unlink()
+ raise ImageError(f"Failed to create image: {e}") from e
+
+
+def _format_exfat(path: Path) -> None:
+ """Format image as exFAT filesystem.
+
+ Args:
+ path: Path to the image file
+
+ Raises:
+ ImageError: If formatting fails
+ """
+ logger.debug(f"Formatting as exFAT: {path}")
+
+ try:
+ result = subprocess.run(
+ ["mkfs.exfat", "-n", "VAULT", str(path)],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ logger.debug(f"mkfs.exfat output: {result.stdout}")
+ except subprocess.CalledProcessError as e:
+ raise ImageError(f"mkfs.exfat failed: {e.stderr}") from e
+ except FileNotFoundError:
+ raise ImageError(
+ "mkfs.exfat not found. Install with: sudo apt install exfatprogs"
+ )
+
+
+def resize_image(path: Path, new_size_mb: int) -> None:
+ """Resize an existing vault image.
+
+ The image must not be mounted during resize.
+
+ Args:
+ path: Path to the .vault file
+ new_size_mb: New size in megabytes (must be larger than current)
+
+ Raises:
+ ImageError: If resize fails
+ """
+ logger.info(f"Resizing image: {path} to {new_size_mb} MB")
+
+ if not path.exists():
+ raise ImageError(f"Image not found: {path}")
+
+ current_size = path.stat().st_size
+ new_size = new_size_mb * 1024 * 1024
+
+ if new_size <= current_size:
+ raise ImageError(
+ f"New size ({new_size_mb} MB) must be larger than current "
+ f"({current_size // (1024 * 1024)} MB)"
+ )
+
+ try:
+ # Extend the sparse file
+ with open(path, "r+b") as f:
+ f.seek(new_size - 1)
+ f.write(b"\0")
+
+ logger.debug(f"File extended to {new_size_mb} MB")
+
+ # Resize exFAT filesystem
+ _resize_exfat(path)
+
+ logger.info(f"Image resized successfully: {path}")
+
+ except Exception as e:
+ raise ImageError(f"Failed to resize image: {e}") from e
+
+
+def _resize_exfat(path: Path) -> None:
+ """Resize exFAT filesystem to fill the image.
+
+ Note: exfatprogs doesn't have a resize tool.
+ We use exfatfsck to check and then the filesystem will
+ automatically use the new space on next mount.
+
+ Args:
+ path: Path to the image file
+
+ Raises:
+ ImageError: If resize fails
+ """
+ logger.debug(f"Checking exFAT filesystem: {path}")
+
+ try:
+ # Run fsck to ensure filesystem integrity
+ result = subprocess.run(
+ ["fsck.exfat", "-a", str(path)],
+ capture_output=True,
+ text=True,
+ )
+ # fsck.exfat returns 0 for clean, 1 for fixed errors
+ if result.returncode not in (0, 1):
+ raise ImageError(f"fsck.exfat failed: {result.stderr}")
+
+ logger.debug(f"fsck.exfat output: {result.stdout}")
+
+ except FileNotFoundError:
+ raise ImageError(
+ "fsck.exfat not found. Install with: sudo apt install exfatprogs"
+ )
+
+
+def get_image_info(path: Path) -> dict:
+ """Get information about a vault image.
+
+ Args:
+ path: Path to the .vault file
+
+ Returns:
+ Dictionary with image information
+
+ Raises:
+ ImageError: If reading info fails
+ """
+ if not path.exists():
+ raise ImageError(f"Image not found: {path}")
+
+ stat = path.stat()
+
+ # Get actual size on disk (sparse file may use less)
+ try:
+ # st_blocks is in 512-byte units
+ actual_size = stat.st_blocks * 512
+ except AttributeError:
+ actual_size = stat.st_size
+
+ return {
+ "path": str(path),
+ "size_mb": stat.st_size // (1024 * 1024),
+ "actual_size_mb": actual_size // (1024 * 1024),
+ "sparse_ratio": actual_size / stat.st_size if stat.st_size > 0 else 0,
+ }
+
+
+def delete_image(path: Path) -> None:
+ """Delete a vault image.
+
+ Args:
+ path: Path to the .vault file
+
+ Raises:
+ ImageError: If deletion fails
+ """
+ logger.info(f"Deleting image: {path}")
+
+ if not path.exists():
+ raise ImageError(f"Image not found: {path}")
+
+ try:
+ path.unlink()
+ logger.info(f"Image deleted: {path}")
+ except OSError as e:
+ raise ImageError(f"Failed to delete image: {e}") from e
diff --git a/src/core/lock.py b/src/core/lock.py
new file mode 100644
index 0000000..76930f5
--- /dev/null
+++ b/src/core/lock.py
@@ -0,0 +1,142 @@
+"""Lock mechanism for exclusive vault access."""
+
+import fcntl
+import os
+from pathlib import Path
+
+
+class VaultLockError(Exception):
+ """Raised when vault lock cannot be acquired."""
+
+
+class VaultLock:
+ """Exclusive lock for vault access.
+
+ Uses fcntl.flock for file-based locking.
+ Only one instance can have the lock at a time.
+
+ Usage:
+ lock = VaultLock(mount_point / ".vault" / "lock")
+ if lock.acquire():
+ try:
+ # work with vault
+ finally:
+ lock.release()
+ """
+
+ def __init__(self, lock_path: Path) -> None:
+ """Initialize lock.
+
+ Args:
+ lock_path: Path to the lock file
+ """
+ self.lock_path = lock_path
+ self._lock_file: int | None = None
+
+ def acquire(self) -> bool:
+ """Try to acquire exclusive lock.
+
+ Returns:
+ True if lock acquired, False if vault is already locked
+
+ Raises:
+ VaultLockError: If lock acquisition fails for unexpected reason
+ """
+ try:
+ # Ensure parent directory exists
+ self.lock_path.parent.mkdir(parents=True, exist_ok=True)
+
+ # Open lock file (create if doesn't exist)
+ self._lock_file = os.open(
+ str(self.lock_path),
+ os.O_RDWR | os.O_CREAT,
+ 0o644,
+ )
+
+ # Try to acquire exclusive lock (non-blocking)
+ fcntl.flock(self._lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
+
+ # Write PID to lock file
+ os.ftruncate(self._lock_file, 0)
+ os.write(self._lock_file, str(os.getpid()).encode())
+
+ return True
+
+ except BlockingIOError:
+ # Lock is held by another process
+ if self._lock_file is not None:
+ os.close(self._lock_file)
+ self._lock_file = None
+ return False
+
+ except OSError as e:
+ if self._lock_file is not None:
+ os.close(self._lock_file)
+ self._lock_file = None
+ raise VaultLockError(f"Failed to acquire lock: {e}") from e
+
+ def release(self) -> None:
+ """Release the lock.
+
+ Safe to call even if lock is not held.
+ """
+ if self._lock_file is not None:
+ try:
+ fcntl.flock(self._lock_file, fcntl.LOCK_UN)
+ os.close(self._lock_file)
+ except OSError:
+ pass # Ignore errors during cleanup
+ finally:
+ self._lock_file = None
+
+ # Remove lock file
+ try:
+ self.lock_path.unlink()
+ except FileNotFoundError:
+ pass
+
+ def is_locked(self) -> bool:
+ """Check if vault is currently locked (by any process).
+
+ Returns:
+ True if locked, False otherwise
+ """
+ if not self.lock_path.exists():
+ return False
+
+ try:
+ fd = os.open(str(self.lock_path), os.O_RDONLY)
+ try:
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ # We got the lock, so it wasn't locked
+ fcntl.flock(fd, fcntl.LOCK_UN)
+ return False
+ except BlockingIOError:
+ # Lock is held
+ return True
+ finally:
+ os.close(fd)
+ except OSError:
+ return False
+
+ def get_owner_pid(self) -> int | None:
+ """Get PID of the process holding the lock.
+
+ Returns:
+ PID if lock file exists and contains valid PID, None otherwise
+ """
+ try:
+ content = self.lock_path.read_text().strip()
+ return int(content)
+ except (FileNotFoundError, ValueError):
+ return None
+
+ def __enter__(self) -> "VaultLock":
+ """Context manager entry."""
+ if not self.acquire():
+ raise VaultLockError("Vault is already locked by another process")
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+ """Context manager exit."""
+ self.release()
diff --git a/src/core/manifest.py b/src/core/manifest.py
new file mode 100644
index 0000000..710b21d
--- /dev/null
+++ b/src/core/manifest.py
@@ -0,0 +1,238 @@
+"""Manifest dataclass for vault metadata."""
+
+import json
+from dataclasses import dataclass, field
+from datetime import datetime
+from pathlib import Path
+from typing import Literal
+from uuid import uuid4
+
+from src.core.file_entry import FileEntry
+
+
+LocationStatus = Literal["active", "unreachable"]
+
+
+@dataclass
+class Location:
+ """Represents a vault replica location.
+
+ Attributes:
+ path: Absolute path to the .vault file
+ last_seen: Last time this location was accessible
+ status: Current status (active/unreachable)
+ """
+
+ path: str
+ last_seen: datetime
+ status: LocationStatus = "active"
+
+ def to_dict(self) -> dict:
+ """Convert to dictionary for JSON serialization."""
+ return {
+ "path": self.path,
+ "last_seen": self.last_seen.isoformat(),
+ "status": self.status,
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "Location":
+ """Create Location from dictionary."""
+ return cls(
+ path=data["path"],
+ last_seen=datetime.fromisoformat(data["last_seen"]),
+ status=data["status"],
+ )
+
+
+@dataclass
+class Manifest:
+ """Vault manifest containing all metadata.
+
+ This is stored as .vault/manifest.json inside each vault image.
+
+ Attributes:
+ vault_id: Unique identifier for the vault (UUID)
+ vault_name: Human-readable name
+ version: Manifest format version
+ created: Creation timestamp
+ last_modified: Last modification timestamp
+ image_size_mb: Size of the vault image in MB
+ locations: List of all replica locations
+ files: List of all files in the vault
+ """
+
+ vault_id: str
+ vault_name: str
+ image_size_mb: int
+ created: datetime
+ last_modified: datetime
+ version: int = 1
+ locations: list[Location] = field(default_factory=list)
+ files: list[FileEntry] = field(default_factory=list)
+
+ @classmethod
+ def create_new(cls, vault_name: str, image_size_mb: int, location_path: str) -> "Manifest":
+ """Create a new manifest for a fresh vault.
+
+ Args:
+ vault_name: Human-readable name for the vault
+ image_size_mb: Size of the vault image in MB
+ location_path: Path to the first .vault file
+
+ Returns:
+ New Manifest instance
+ """
+ now = datetime.now()
+ resolved = str(Path(location_path).resolve())
+ return cls(
+ vault_id=str(uuid4()),
+ vault_name=vault_name,
+ image_size_mb=image_size_mb,
+ created=now,
+ last_modified=now,
+ locations=[Location(path=resolved, last_seen=now, status="active")],
+ files=[],
+ )
+
+ def to_dict(self) -> dict:
+ """Convert to dictionary for JSON serialization."""
+ return {
+ "vault_id": self.vault_id,
+ "vault_name": self.vault_name,
+ "version": self.version,
+ "created": self.created.isoformat(),
+ "last_modified": self.last_modified.isoformat(),
+ "image_size_mb": self.image_size_mb,
+ "locations": [loc.to_dict() for loc in self.locations],
+ "files": [f.to_dict() for f in self.files],
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "Manifest":
+ """Create Manifest from dictionary."""
+ return cls(
+ vault_id=data["vault_id"],
+ vault_name=data["vault_name"],
+ version=data["version"],
+ created=datetime.fromisoformat(data["created"]),
+ last_modified=datetime.fromisoformat(data["last_modified"]),
+ image_size_mb=data["image_size_mb"],
+ locations=[Location.from_dict(loc) for loc in data["locations"]],
+ files=[FileEntry.from_dict(f) for f in data["files"]],
+ )
+
+ def save(self, mount_point: Path) -> None:
+ """Save manifest to .vault/manifest.json.
+
+ Args:
+ mount_point: Path where the vault is mounted
+ """
+ vault_dir = mount_point / ".vault"
+ vault_dir.mkdir(exist_ok=True)
+
+ manifest_path = vault_dir / "manifest.json"
+ with open(manifest_path, "w", encoding="utf-8") as f:
+ json.dump(self.to_dict(), f, indent=2)
+
+ @classmethod
+ def load(cls, mount_point: Path) -> "Manifest":
+ """Load manifest from .vault/manifest.json.
+
+ Args:
+ mount_point: Path where the vault is mounted
+
+ Returns:
+ Loaded Manifest instance
+
+ Raises:
+ FileNotFoundError: If manifest doesn't exist
+ """
+ manifest_path = mount_point / ".vault" / "manifest.json"
+ with open(manifest_path, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ return cls.from_dict(data)
+
+ def add_location(self, path: str) -> None:
+ """Add a new replica location.
+
+ Args:
+ path: Absolute path to the .vault file
+ """
+ resolved = str(Path(path).resolve())
+ # Don't add duplicate locations
+ for loc in self.locations:
+ if str(Path(loc.path).resolve()) == resolved:
+ loc.status = "active"
+ loc.last_seen = datetime.now()
+ self.last_modified = datetime.now()
+ return
+
+ self.locations.append(
+ Location(path=resolved, last_seen=datetime.now(), status="active")
+ )
+ self.last_modified = datetime.now()
+
+ def update_location_status(self, path: str, status: LocationStatus) -> None:
+ """Update status of a location.
+
+ Args:
+ path: Path to the location
+ status: New status
+ """
+ resolved = str(Path(path).resolve())
+ for loc in self.locations:
+ if str(Path(loc.path).resolve()) == resolved:
+ loc.status = status
+ if status == "active":
+ loc.last_seen = datetime.now()
+ break
+ self.last_modified = datetime.now()
+
+ def add_file(self, file_entry: FileEntry) -> None:
+ """Add or update a file entry.
+
+ Args:
+ file_entry: File entry to add/update
+ """
+ # Remove existing entry with same path
+ self.files = [f for f in self.files if f.path != file_entry.path]
+ self.files.append(file_entry)
+ self.last_modified = datetime.now()
+
+ def add_file_from_path(self, base_path: Path, file_path: Path) -> FileEntry:
+ """Add a file entry from a file path.
+
+ Args:
+ base_path: Base path (mount point) for relative path calculation
+ file_path: Absolute path to the file
+
+ Returns:
+ The created FileEntry
+ """
+ entry = FileEntry.from_path(base_path, file_path)
+ self.add_file(entry)
+ return entry
+
+ def remove_file(self, path: str) -> None:
+ """Remove a file entry by path.
+
+ Args:
+ path: Relative path of the file to remove
+ """
+ self.files = [f for f in self.files if f.path != path]
+ self.last_modified = datetime.now()
+
+ def get_file(self, path: str) -> FileEntry | None:
+ """Get file entry by path.
+
+ Args:
+ path: Relative path of the file
+
+ Returns:
+ FileEntry if found, None otherwise
+ """
+ for f in self.files:
+ if f.path == path:
+ return f
+ return None
diff --git a/src/core/sync_manager.py b/src/core/sync_manager.py
new file mode 100644
index 0000000..67fa1c3
--- /dev/null
+++ b/src/core/sync_manager.py
@@ -0,0 +1,427 @@
+"""Synchronization manager for coordinating changes between replicas.
+
+Handles:
+- Change detection via file watcher
+- Propagation of changes to all replicas
+- Manifest comparison and sync on reconnect
+"""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from enum import Enum
+from pathlib import Path
+
+from loguru import logger
+
+from src.core.file_sync import (
+ ProgressCallback,
+ copy_file_with_progress,
+ delete_directory,
+ delete_file,
+ move_file,
+)
+from src.core.file_watcher import EventType, FileEvent, FileWatcher
+from src.core.manifest import Manifest
+
+
+class SyncStatus(Enum):
+ """Status of synchronization."""
+
+ IDLE = "idle"
+ SYNCING = "syncing"
+ ERROR = "error"
+
+
+@dataclass
+class SyncEvent:
+ """Event describing a sync operation."""
+
+ event_type: EventType
+ relative_path: str
+ source_mount: Path
+ target_mounts: list[Path]
+ success: bool = True
+ error: str | None = None
+
+
+# Type alias for sync event callbacks
+SyncEventCallback = Callable[[SyncEvent], None]
+
+
+@dataclass
+class ReplicaMount:
+ """Represents a mounted replica."""
+
+ mount_point: Path
+ image_path: Path
+ is_primary: bool = False
+
+ def get_file_path(self, relative_path: str) -> Path:
+ """Get full path for a relative file path."""
+ return self.mount_point / relative_path
+
+
+class SyncManager:
+ """Manages synchronization between multiple vault replicas."""
+
+ def __init__(
+ self,
+ on_sync_event: SyncEventCallback | None = None,
+ on_progress: ProgressCallback | None = None,
+ ) -> None:
+ self._replicas: list[ReplicaMount] = []
+ self._primary: ReplicaMount | None = None
+ self._watcher: FileWatcher | None = None
+ self._status = SyncStatus.IDLE
+ self._on_sync_event = on_sync_event
+ self._on_progress = on_progress
+ self._paused = False
+
+ @property
+ def status(self) -> SyncStatus:
+ """Get current sync status."""
+ return self._status
+
+ @property
+ def replica_count(self) -> int:
+ """Get number of registered replicas."""
+ return len(self._replicas)
+
+ @property
+ def primary_mount(self) -> Path | None:
+ """Get primary mount point."""
+ return self._primary.mount_point if self._primary else None
+
+ def add_replica(
+ self,
+ mount_point: Path,
+ image_path: Path,
+ is_primary: bool = False,
+ ) -> None:
+ """Add a replica to the sync manager.
+
+ Args:
+ mount_point: Path where replica is mounted
+ image_path: Path to the .vault image file
+ is_primary: If True, this is the primary replica (user-facing)
+ """
+ replica = ReplicaMount(
+ mount_point=mount_point,
+ image_path=image_path,
+ is_primary=is_primary,
+ )
+ self._replicas.append(replica)
+
+ if is_primary:
+ if self._primary is not None:
+ logger.warning("Replacing existing primary replica")
+ self._primary = replica
+ logger.info(f"Primary replica set: {mount_point}")
+ else:
+ logger.info(f"Secondary replica added: {mount_point}")
+
+ def remove_replica(self, mount_point: Path) -> bool:
+ """Remove a replica from the sync manager.
+
+ Args:
+ mount_point: Mount point of replica to remove
+
+ Returns:
+ True if replica was removed, False if not found
+ """
+ for i, replica in enumerate(self._replicas):
+ if replica.mount_point == mount_point:
+ if replica.is_primary:
+ self._primary = None
+ self.stop_watching()
+ del self._replicas[i]
+ logger.info(f"Replica removed: {mount_point}")
+ return True
+ return False
+
+ def start_watching(self) -> None:
+ """Start watching the primary replica for changes."""
+ if self._primary is None:
+ raise ValueError("No primary replica set")
+
+ if self._watcher is not None:
+ logger.warning("Watcher already running")
+ return
+
+ self._watcher = FileWatcher(
+ watch_path=self._primary.mount_point,
+ callback=self._handle_file_event,
+ ignore_patterns=[".vault"],
+ )
+ self._watcher.start()
+ logger.info(f"Started watching: {self._primary.mount_point}")
+
+ def stop_watching(self) -> None:
+ """Stop watching for changes."""
+ if self._watcher is not None:
+ self._watcher.stop()
+ self._watcher = None
+ logger.info("Stopped watching")
+
+ def pause_sync(self) -> None:
+ """Temporarily pause synchronization."""
+ self._paused = True
+ logger.debug("Sync paused")
+
+ def resume_sync(self) -> None:
+ """Resume synchronization."""
+ self._paused = False
+ logger.debug("Sync resumed")
+
+ def _handle_file_event(self, event: FileEvent) -> None:
+ """Handle a file system event from the watcher."""
+ if self._paused:
+ logger.debug(f"Sync paused, ignoring event: {event}")
+ return
+
+ logger.debug(f"Handling event: {event}")
+
+ try:
+ self._status = SyncStatus.SYNCING
+
+ if event.event_type == EventType.CREATED:
+ self._propagate_create(event)
+ elif event.event_type == EventType.MODIFIED:
+ self._propagate_modify(event)
+ elif event.event_type == EventType.DELETED:
+ self._propagate_delete(event)
+ elif event.event_type == EventType.MOVED:
+ self._propagate_move(event)
+
+ self._status = SyncStatus.IDLE
+
+ except Exception as e:
+ logger.error(f"Error handling event {event}: {e}")
+ self._status = SyncStatus.ERROR
+ self._emit_sync_event(
+ event.event_type,
+ event.path,
+ [],
+ success=False,
+ error=str(e),
+ )
+
+ def _get_secondary_replicas(self) -> list[ReplicaMount]:
+ """Get all non-primary replicas."""
+ return [r for r in self._replicas if not r.is_primary]
+
+ def _propagate_create(self, event: FileEvent) -> None:
+ """Propagate file/directory creation to all replicas."""
+ if self._primary is None:
+ return
+
+ src_path = self._primary.get_file_path(event.path)
+ target_mounts: list[Path] = []
+
+ for replica in self._get_secondary_replicas():
+ dst_path = replica.get_file_path(event.path)
+ try:
+ if event.is_directory:
+ dst_path.mkdir(parents=True, exist_ok=True)
+ else:
+ copy_file_with_progress(src_path, dst_path, self._on_progress)
+ target_mounts.append(replica.mount_point)
+ except Exception as e:
+ logger.error(f"Failed to propagate create to {replica.mount_point}: {e}")
+
+ self._emit_sync_event(EventType.CREATED, event.path, target_mounts)
+
+ def _propagate_modify(self, event: FileEvent) -> None:
+ """Propagate file modification to all replicas."""
+ if self._primary is None:
+ return
+
+ src_path = self._primary.get_file_path(event.path)
+ target_mounts: list[Path] = []
+
+ # Skip if source doesn't exist (might have been deleted quickly after modify)
+ if not src_path.exists():
+ logger.debug(f"Source no longer exists, skipping modify: {event.path}")
+ return
+
+ for replica in self._get_secondary_replicas():
+ dst_path = replica.get_file_path(event.path)
+ try:
+ copy_file_with_progress(src_path, dst_path, self._on_progress)
+ target_mounts.append(replica.mount_point)
+ except Exception as e:
+ logger.error(f"Failed to propagate modify to {replica.mount_point}: {e}")
+
+ self._emit_sync_event(EventType.MODIFIED, event.path, target_mounts)
+
+ def _propagate_delete(self, event: FileEvent) -> None:
+ """Propagate file/directory deletion to all replicas."""
+ target_mounts: list[Path] = []
+
+ for replica in self._get_secondary_replicas():
+ dst_path = replica.get_file_path(event.path)
+ try:
+ if dst_path.exists():
+ if dst_path.is_dir():
+ delete_directory(dst_path)
+ else:
+ delete_file(dst_path)
+ target_mounts.append(replica.mount_point)
+ except Exception as e:
+ logger.error(f"Failed to propagate delete to {replica.mount_point}: {e}")
+
+ self._emit_sync_event(EventType.DELETED, event.path, target_mounts)
+
+ def _propagate_move(self, event: FileEvent) -> None:
+ """Propagate file/directory move to all replicas."""
+ if event.dest_path is None:
+ logger.warning(f"Move event without dest_path: {event}")
+ return
+
+ target_mounts: list[Path] = []
+
+ for replica in self._get_secondary_replicas():
+ src_path = replica.get_file_path(event.path)
+ dst_path = replica.get_file_path(event.dest_path)
+ try:
+ if src_path.exists():
+ move_file(src_path, dst_path)
+ target_mounts.append(replica.mount_point)
+ except Exception as e:
+ logger.error(f"Failed to propagate move to {replica.mount_point}: {e}")
+
+ self._emit_sync_event(EventType.MOVED, event.path, target_mounts)
+
+ def _emit_sync_event(
+ self,
+ event_type: EventType,
+ path: str,
+ target_mounts: list[Path],
+ success: bool = True,
+ error: str | None = None,
+ ) -> None:
+ """Emit a sync event to the callback."""
+ if self._on_sync_event is None or self._primary is None:
+ return
+
+ sync_event = SyncEvent(
+ event_type=event_type,
+ relative_path=path,
+ source_mount=self._primary.mount_point,
+ target_mounts=target_mounts,
+ success=success,
+ error=error,
+ )
+ self._on_sync_event(sync_event)
+
+ def sync_from_manifest(
+ self,
+ source_manifest: Manifest,
+ target_mount: Path,
+ target_manifest: Manifest,
+ ) -> int:
+ """Synchronize files based on manifest comparison.
+
+ Compares two manifests and syncs files from source to target
+ based on timestamps (newer wins).
+
+ Args:
+ source_manifest: Manifest of source replica
+ target_mount: Mount point of target replica
+ target_manifest: Manifest of target replica
+
+ Returns:
+ Number of files synchronized
+ """
+ if self._primary is None:
+ raise ValueError("No primary replica set")
+
+ synced_count = 0
+
+ # Build lookup of target files
+ target_files = {f.path: f for f in target_manifest.files}
+
+ for source_file in source_manifest.files:
+ target_file = target_files.get(source_file.path)
+
+ should_copy = False
+ if target_file is None:
+ # File doesn't exist in target
+ should_copy = True
+ logger.debug(f"File missing in target: {source_file.path}")
+ elif source_file.is_newer_than(target_file):
+ # Source file is newer
+ should_copy = True
+ logger.debug(f"Source file newer: {source_file.path}")
+
+ if should_copy:
+ src_path = self._primary.get_file_path(source_file.path)
+ dst_path = target_mount / source_file.path
+
+ try:
+ copy_file_with_progress(src_path, dst_path, self._on_progress)
+ synced_count += 1
+ except Exception as e:
+ logger.error(f"Failed to sync {source_file.path}: {e}")
+
+ # Check for files in target that don't exist in source (deletions)
+ source_files = {f.path for f in source_manifest.files}
+ for target_file in target_manifest.files:
+ if target_file.path not in source_files:
+ # File was deleted in source
+ dst_path = target_mount / target_file.path
+ try:
+ if dst_path.exists():
+ delete_file(dst_path)
+ synced_count += 1
+ logger.debug(f"Deleted from target: {target_file.path}")
+ except Exception as e:
+ logger.error(f"Failed to delete {target_file.path}: {e}")
+
+ return synced_count
+
+ def full_sync(self) -> dict[Path, int]:
+ """Perform full synchronization across all replicas.
+
+ Syncs from primary to all secondary replicas based on manifests.
+
+ Returns:
+ Dictionary mapping mount points to number of files synced
+ """
+ if self._primary is None:
+ raise ValueError("No primary replica set")
+
+ results: dict[Path, int] = {}
+
+ # Load primary manifest
+ try:
+ primary_manifest = Manifest.load(self._primary.mount_point)
+ except FileNotFoundError:
+ logger.warning("Primary manifest not found, creating empty manifest")
+ primary_manifest = Manifest.create_new(
+ vault_name="Vault",
+ image_size_mb=0,
+ location_path=str(self._primary.image_path),
+ )
+
+ # Sync to each secondary
+ for replica in self._get_secondary_replicas():
+ try:
+ target_manifest = Manifest.load(replica.mount_point)
+ except FileNotFoundError:
+ logger.warning(f"Target manifest not found: {replica.mount_point}")
+ target_manifest = Manifest.create_new(
+ vault_name="Vault",
+ image_size_mb=0,
+ location_path=str(replica.image_path),
+ )
+
+ synced = self.sync_from_manifest(
+ primary_manifest,
+ replica.mount_point,
+ target_manifest,
+ )
+ results[replica.mount_point] = synced
+ logger.info(f"Synced {synced} files to {replica.mount_point}")
+
+ return results
diff --git a/src/core/vault.py b/src/core/vault.py
new file mode 100644
index 0000000..3ab033a
--- /dev/null
+++ b/src/core/vault.py
@@ -0,0 +1,668 @@
+"""Main Vault class for managing multiple container replicas.
+
+This is the primary interface for working with a vault - it handles:
+- Opening/closing vaults with multiple replicas
+- Coordinating mounting of all containers
+- Setting up synchronization between replicas
+- Lock management for exclusive access
+"""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from enum import Enum
+from pathlib import Path
+
+from loguru import logger
+
+from src.core.container import Container, ContainerError
+from src.core.file_sync import ProgressCallback
+from src.core.lock import VaultLock
+from src.core.manifest import Manifest
+from src.core.sync_manager import SyncEventCallback, SyncManager, SyncStatus
+
+
+class VaultState(Enum):
+ """State of a vault."""
+
+ CLOSED = "closed"
+ OPENING = "opening"
+ OPEN = "open"
+ SYNCING = "syncing"
+ ERROR = "error"
+
+
+class VaultError(Exception):
+ """Raised when vault operation fails."""
+
+
+@dataclass
+class ReplicaInfo:
+ """Information about a replica."""
+
+ image_path: Path
+ mount_point: Path | None = None
+ is_primary: bool = False
+ is_mounted: bool = False
+ error: str | None = None
+
+
+class Vault:
+ """Manages a vault with multiple replicas.
+
+ A vault consists of:
+ - One primary container (user-facing mount point)
+ - Zero or more secondary containers (internal sync targets)
+ - A sync manager for propagating changes
+
+ Example usage:
+ vault = Vault()
+ vault.open("/path/to/primary.vault", "/home/user/Vault")
+ vault.add_replica("/path/to/backup.vault")
+ # ... user works with files in /home/user/Vault ...
+ vault.close()
+ """
+
+ def __init__(
+ self,
+ on_state_change: "Callable[[VaultState], None] | None" = None,
+ on_sync_event: SyncEventCallback | None = None,
+ on_progress: ProgressCallback | None = None,
+ ) -> None:
+ self._state = VaultState.CLOSED
+ self._primary_container: Container | None = None
+ self._secondary_containers: list[Container] = []
+ self._sync_manager: SyncManager | None = None
+ self._lock: VaultLock | None = None
+ self._manifest: Manifest | None = None
+
+ self._on_state_change = on_state_change
+ self._on_sync_event = on_sync_event
+ self._on_progress = on_progress
+
+ @property
+ def state(self) -> VaultState:
+ """Get current vault state."""
+ return self._state
+
+ @property
+ def is_open(self) -> bool:
+ """Check if vault is open."""
+ return self._state == VaultState.OPEN or self._state == VaultState.SYNCING
+
+ @property
+ def mount_point(self) -> Path | None:
+ """Get primary mount point."""
+ if self._primary_container and self._primary_container.is_mounted():
+ return self._primary_container.mount_point
+ return None
+
+ @property
+ def manifest(self) -> Manifest | None:
+ """Get current manifest."""
+ return self._manifest
+
+ @property
+ def replica_count(self) -> int:
+ """Get total number of replicas (primary + secondary)."""
+ count = 1 if self._primary_container else 0
+ return count + len(self._secondary_containers)
+
+ @property
+ def sync_status(self) -> SyncStatus:
+ """Get current sync status."""
+ if self._sync_manager:
+ return self._sync_manager.status
+ return SyncStatus.IDLE
+
+ def _set_state(self, state: VaultState) -> None:
+ """Update state and notify callback."""
+ self._state = state
+ if self._on_state_change:
+ self._on_state_change(state)
+
+ def open(self, image_path: Path, mount_point: Path | None = None) -> Path:
+ """Open a vault from a .vault image.
+
+ Args:
+ image_path: Path to the primary .vault file
+ mount_point: Optional custom mount point
+
+ Returns:
+ Path where vault is mounted
+
+ Raises:
+ VaultError: If opening fails
+ """
+ if self.is_open:
+ raise VaultError("Vault is already open")
+
+ self._set_state(VaultState.OPENING)
+ image_path = Path(image_path).resolve()
+
+ try:
+ # Acquire lock
+ lock_path = image_path.parent / f".{image_path.stem}.lock"
+ self._lock = VaultLock(lock_path)
+ if not self._lock.acquire():
+ raise VaultError(f"Vault is locked by another process (PID: {self._lock.get_owner_pid()})")
+
+ # Create and mount primary container
+ self._primary_container = Container(image_path)
+ actual_mount = self._primary_container.mount(mount_point)
+
+ # Initialize sync manager
+ self._sync_manager = SyncManager(
+ on_sync_event=self._on_sync_event,
+ on_progress=self._on_progress,
+ )
+ self._sync_manager.add_replica(
+ actual_mount,
+ image_path,
+ is_primary=True,
+ )
+
+ # Load or create manifest
+ try:
+ self._manifest = Manifest.load(actual_mount)
+ logger.info(f"Loaded manifest: {self._manifest.vault_name}")
+ except FileNotFoundError:
+ logger.warning("No manifest found, creating new one")
+ self._manifest = Manifest.create_new(
+ vault_name=image_path.stem,
+ image_size_mb=0, # TODO: get actual size
+ location_path=str(image_path),
+ )
+ self._manifest.save(actual_mount)
+
+ # Try to mount secondary replicas from manifest
+ self._mount_secondary_replicas()
+
+ # Start file watching
+ self._sync_manager.start_watching()
+
+ self._set_state(VaultState.OPEN)
+ logger.info(f"Vault opened: {self._manifest.vault_name}")
+ return actual_mount
+
+ except Exception as e:
+ self._cleanup()
+ self._set_state(VaultState.ERROR)
+ raise VaultError(f"Failed to open vault: {e}") from e
+
+ def _mount_secondary_replicas(self) -> None:
+ """Try to mount secondary replicas from manifest locations."""
+ if not self._manifest or not self._sync_manager:
+ return
+
+ for location in self._manifest.locations:
+ location_path = Path(location.path)
+
+ # Skip primary (already mounted) - resolve both for consistent comparison
+ if self._primary_container and location_path.resolve() == self._primary_container.image_path.resolve():
+ continue
+
+ # Try to mount if available
+ if location_path.exists():
+ try:
+ container = Container(location_path)
+ mount = container.mount()
+ self._secondary_containers.append(container)
+ self._sync_manager.add_replica(mount, location_path)
+ self._manifest.update_location_status(str(location_path), "active")
+ logger.info(f"Secondary replica mounted: {location_path}")
+ except ContainerError as e:
+ logger.warning(f"Failed to mount secondary replica {location_path}: {e}")
+ self._manifest.update_location_status(str(location_path), "unreachable")
+ else:
+ logger.warning(f"Secondary replica not available: {location_path}")
+ self._manifest.update_location_status(str(location_path), "unreachable")
+
+ def check_replica_availability(self) -> dict[str, bool]:
+ """Check availability of all replica locations.
+
+ Returns:
+ Dictionary mapping location paths to availability status
+ """
+ if not self._manifest:
+ return {}
+
+ result: dict[str, bool] = {}
+
+ for location in self._manifest.locations:
+ location_path = Path(location.path)
+ is_available = location_path.exists()
+ result[location.path] = is_available
+
+ # Update manifest status
+ if is_available and location.status == "unreachable":
+ self._manifest.update_location_status(location.path, "active")
+ elif not is_available and location.status == "active":
+ self._manifest.update_location_status(location.path, "unreachable")
+
+ return result
+
+ def reconnect_unavailable_replicas(self) -> int:
+ """Try to reconnect replicas that were previously unavailable.
+
+ Returns:
+ Number of replicas successfully reconnected
+ """
+ if not self._manifest or not self._sync_manager or not self._primary_container:
+ return 0
+
+ reconnected = 0
+
+ # Get currently mounted paths (resolved for consistent comparison)
+ mounted_paths = {self._primary_container.image_path.resolve()}
+ for container in self._secondary_containers:
+ mounted_paths.add(container.image_path.resolve())
+
+ for location in self._manifest.locations:
+ location_path = Path(location.path)
+
+ # Skip if already mounted
+ if location_path.resolve() in mounted_paths:
+ continue
+
+ # Try to mount if now available
+ if location_path.exists():
+ try:
+ container = Container(location_path)
+ mount = container.mount()
+ self._secondary_containers.append(container)
+ self._sync_manager.add_replica(mount, location_path)
+ self._manifest.update_location_status(location.path, "active")
+
+ # Sync to newly connected replica
+ self._scan_and_update_manifest()
+ self._sync_manager.full_sync()
+
+ logger.info(f"Reconnected replica: {location_path}")
+ reconnected += 1
+
+ except ContainerError as e:
+ logger.warning(f"Failed to reconnect replica {location_path}: {e}")
+
+ return reconnected
+
+ def get_unavailable_replicas(self) -> list[str]:
+ """Get list of unavailable replica paths.
+
+ Returns:
+ List of paths to unavailable replicas
+ """
+ if not self._manifest or not self._primary_container:
+ return []
+
+ mounted_paths = {str(self._primary_container.image_path.resolve())}
+ for container in self._secondary_containers:
+ mounted_paths.add(str(container.image_path.resolve()))
+
+ unavailable = []
+ for location in self._manifest.locations:
+ if str(Path(location.path).resolve()) not in mounted_paths:
+ unavailable.append(location.path)
+
+ return unavailable
+
+ def add_replica(self, image_path: Path) -> Path:
+ """Add a new replica to the vault.
+
+ Creates a new .vault image and syncs all content from primary.
+
+ Args:
+ image_path: Path where to create new .vault file
+
+ Returns:
+ Mount point of new replica
+
+ Raises:
+ VaultError: If adding replica fails
+ """
+ if not self.is_open:
+ raise VaultError("Vault must be open to add replica")
+
+ if not self._sync_manager or not self._primary_container or not self._manifest:
+ raise VaultError("Vault not properly initialized")
+
+ image_path = Path(image_path).resolve()
+
+ try:
+ # Create new container with same settings as primary
+ from src.core.image_manager import create_sparse_image
+
+ # Get primary size
+ primary_size = self._manifest.image_size_mb
+ if primary_size == 0:
+ # Estimate from primary image file size
+ primary_size = int(self._primary_container.image_path.stat().st_size / (1024 * 1024))
+
+ create_sparse_image(image_path, primary_size)
+
+ # Mount new container
+ container = Container(image_path)
+ mount = container.mount()
+
+ # Create vault directory and save manifest copy
+ (mount / ".vault").mkdir(exist_ok=True)
+ self._manifest.add_location(str(image_path))
+ self._manifest.save(mount)
+
+ # Register with sync manager
+ self._secondary_containers.append(container)
+ self._sync_manager.add_replica(mount, image_path)
+
+ # Update manifest with actual files before sync
+ self._scan_and_update_manifest()
+
+ # Perform full sync to new replica
+ self._set_state(VaultState.SYNCING)
+ self._sync_manager.full_sync()
+ self._set_state(VaultState.OPEN)
+
+ # Save updated manifest to all replicas
+ self._save_manifest_to_all()
+
+ logger.info(f"Replica added: {image_path}")
+ return mount
+
+ except Exception as e:
+ raise VaultError(f"Failed to add replica: {e}") from e
+
+ def remove_replica(self, image_path: Path) -> None:
+ """Remove a replica from the vault.
+
+ Unmounts and removes from manifest, but doesn't delete the file.
+
+ Args:
+ image_path: Path to the replica to remove
+
+ Raises:
+ VaultError: If removing fails
+ """
+ if not self.is_open:
+ raise VaultError("Vault must be open to remove replica")
+
+ image_path = Path(image_path).resolve()
+
+ # Can't remove primary
+ if self._primary_container and image_path == self._primary_container.image_path.resolve():
+ raise VaultError("Cannot remove primary replica")
+
+ # Find and unmount secondary
+ for i, container in enumerate(self._secondary_containers):
+ if container.image_path.resolve() == image_path:
+ if self._sync_manager:
+ self._sync_manager.remove_replica(container.mount_point) # type: ignore
+ container.unmount()
+ del self._secondary_containers[i]
+
+ # Update manifest
+ if self._manifest:
+ self._manifest.locations = [
+ loc for loc in self._manifest.locations if loc.path != str(image_path)
+ ]
+ self._save_manifest_to_all()
+
+ logger.info(f"Replica removed: {image_path}")
+ return
+
+ raise VaultError(f"Replica not found: {image_path}")
+
+ def get_replicas(self) -> list[ReplicaInfo]:
+ """Get information about all replicas.
+
+ Returns:
+ List of ReplicaInfo for all replicas
+ """
+ replicas = []
+
+ if self._primary_container:
+ replicas.append(ReplicaInfo(
+ image_path=self._primary_container.image_path,
+ mount_point=self._primary_container.mount_point,
+ is_primary=True,
+ is_mounted=self._primary_container.is_mounted(),
+ ))
+
+ for container in self._secondary_containers:
+ replicas.append(ReplicaInfo(
+ image_path=container.image_path,
+ mount_point=container.mount_point,
+ is_primary=False,
+ is_mounted=container.is_mounted(),
+ ))
+
+ return replicas
+
+ def _scan_and_update_manifest(self) -> None:
+ """Scan primary mount point and update manifest with actual files."""
+ if not self._manifest or not self._primary_container or not self._primary_container.mount_point:
+ return
+
+ mount_point = self._primary_container.mount_point
+
+ # Walk through all files in mount point
+ for file_path in mount_point.rglob("*"):
+ # Skip directories and .vault directory
+ if file_path.is_dir():
+ continue
+ if ".vault" in file_path.parts:
+ continue
+
+ # Add or update file in manifest
+ self._manifest.add_file_from_path(mount_point, file_path)
+
+ logger.debug(f"Manifest updated with {len(self._manifest.files)} files")
+
+ # Save manifest so full_sync can load it
+ self._manifest.save(mount_point)
+
+ def sync(self) -> None:
+ """Manually trigger full synchronization.
+
+ Raises:
+ VaultError: If sync fails
+ """
+ if not self.is_open or not self._sync_manager:
+ raise VaultError("Vault must be open to sync")
+
+ self._set_state(VaultState.SYNCING)
+ try:
+ self._sync_manager.full_sync()
+ finally:
+ self._set_state(VaultState.OPEN)
+
+ def get_space_info(self) -> dict[str, int | float] | None:
+ """Get disk space information for the vault.
+
+ Returns:
+ Dictionary with total, used, free space in bytes, or None if not open
+ """
+ if not self.is_open or not self._primary_container or not self._primary_container.mount_point:
+ return None
+
+ import shutil
+
+ usage = shutil.disk_usage(self._primary_container.mount_point)
+ return {
+ "total": usage.total,
+ "used": usage.used,
+ "free": usage.free,
+ "percent_used": (usage.used / usage.total * 100) if usage.total > 0 else 0.0,
+ }
+
+ def check_space_warning(self, threshold_percent: float = 90.0) -> bool:
+ """Check if vault space usage exceeds warning threshold.
+
+ Args:
+ threshold_percent: Warning threshold (default 90%)
+
+ Returns:
+ True if space usage exceeds threshold
+ """
+ info = self.get_space_info()
+ if info is None:
+ return False
+ return info["percent_used"] >= threshold_percent
+
+ def resize(self, new_size_mb: int) -> None:
+ """Resize the vault to a new size.
+
+ This requires temporarily unmounting and remounting all containers.
+
+ Args:
+ new_size_mb: New size in megabytes
+
+ Raises:
+ VaultError: If resize fails
+ """
+ if not self.is_open:
+ raise VaultError("Vault must be open to resize")
+
+ if not self._manifest:
+ raise VaultError("No manifest loaded")
+
+ from src.core.image_manager import resize_image
+
+ logger.info(f"Resizing vault to {new_size_mb} MB")
+
+ # Stop watching during resize
+ if self._sync_manager:
+ self._sync_manager.stop_watching()
+
+ try:
+ # Save manifest before unmounting
+ self._save_manifest_to_all()
+
+ # Unmount all containers
+ containers_to_remount: list[tuple[Path, bool]] = []
+
+ if self._primary_container and self._primary_container.is_mounted():
+ containers_to_remount.append((self._primary_container.image_path, True))
+ self._primary_container.unmount()
+
+ for container in self._secondary_containers:
+ if container.is_mounted():
+ containers_to_remount.append((container.image_path, False))
+ container.unmount()
+
+ # Resize all images
+ for image_path, _ in containers_to_remount:
+ try:
+ resize_image(image_path, new_size_mb)
+ except Exception as e:
+ logger.error(f"Failed to resize {image_path}: {e}")
+
+ # Remount containers
+ self._secondary_containers.clear()
+ if self._sync_manager:
+ # Reset sync manager
+ self._sync_manager = SyncManager(
+ on_sync_event=self._on_sync_event,
+ on_progress=self._on_progress,
+ )
+
+ for image_path, is_primary in containers_to_remount:
+ try:
+ container = Container(image_path)
+ mount = container.mount()
+
+ if is_primary:
+ self._primary_container = container
+ if self._sync_manager:
+ self._sync_manager.add_replica(mount, image_path, is_primary=True)
+ else:
+ self._secondary_containers.append(container)
+ if self._sync_manager:
+ self._sync_manager.add_replica(mount, image_path)
+
+ except Exception as e:
+ logger.error(f"Failed to remount {image_path}: {e}")
+
+ # Update manifest with new size
+ self._manifest.image_size_mb = new_size_mb
+ self._save_manifest_to_all()
+
+ # Restart watching
+ if self._sync_manager:
+ self._sync_manager.start_watching()
+
+ logger.info(f"Vault resized to {new_size_mb} MB")
+
+ except Exception as e:
+ raise VaultError(f"Failed to resize vault: {e}") from e
+
+ def _save_manifest_to_all(self) -> None:
+ """Save manifest to all mounted replicas."""
+ if not self._manifest:
+ return
+
+ if self._primary_container and self._primary_container.mount_point:
+ self._manifest.save(self._primary_container.mount_point)
+
+ for container in self._secondary_containers:
+ if container.mount_point:
+ self._manifest.save(container.mount_point)
+
+ def close(self) -> None:
+ """Close the vault.
+
+ Stops sync, saves manifest, unmounts all containers.
+ """
+ if not self.is_open:
+ return
+
+ logger.info("Closing vault...")
+
+ try:
+ # Stop watching
+ if self._sync_manager:
+ self._sync_manager.stop_watching()
+
+ # Save manifest to all replicas
+ self._save_manifest_to_all()
+
+ except Exception as e:
+ logger.error(f"Error during vault close: {e}")
+
+ finally:
+ self._cleanup()
+ self._set_state(VaultState.CLOSED)
+ logger.info("Vault closed")
+
+ def _cleanup(self) -> None:
+ """Clean up all resources."""
+ # Unmount secondary containers
+ for container in self._secondary_containers:
+ try:
+ if container.is_mounted():
+ container.unmount()
+ except Exception as e:
+ logger.error(f"Error unmounting secondary: {e}")
+ self._secondary_containers.clear()
+
+ # Unmount primary
+ if self._primary_container:
+ try:
+ if self._primary_container.is_mounted():
+ self._primary_container.unmount()
+ except Exception as e:
+ logger.error(f"Error unmounting primary: {e}")
+ self._primary_container = None
+
+ # Release lock
+ if self._lock:
+ self._lock.release()
+ self._lock = None
+
+ self._sync_manager = None
+ self._manifest = None
+
+ def __enter__(self) -> "Vault":
+ """Context manager - returns self (must call open() separately)."""
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+ """Context manager exit - close vault."""
+ self.close()
diff --git a/src/ui/__init__.py b/src/ui/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/ui/dialogs/__init__.py b/src/ui/dialogs/__init__.py
new file mode 100644
index 0000000..a54c55a
--- /dev/null
+++ b/src/ui/dialogs/__init__.py
@@ -0,0 +1 @@
+# Dialog windows
diff --git a/src/ui/dialogs/manage_replicas.py b/src/ui/dialogs/manage_replicas.py
new file mode 100644
index 0000000..f875e1b
--- /dev/null
+++ b/src/ui/dialogs/manage_replicas.py
@@ -0,0 +1,125 @@
+"""Replica management dialog."""
+
+from pathlib import Path
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import (
+ QDialog,
+ QHBoxLayout,
+ QHeaderView,
+ QLabel,
+ QMessageBox,
+ QPushButton,
+ QTableWidget,
+ QTableWidgetItem,
+ QVBoxLayout,
+ QWidget,
+)
+
+from src.core.vault import Vault
+
+
+class ManageReplicasDialog(QDialog):
+ """Dialog for managing vault replicas."""
+
+ def __init__(self, vault: Vault, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self._vault = vault
+ self.setWindowTitle("Spravovat repliky")
+ self.setMinimumSize(600, 400)
+ self._setup_ui()
+ self._refresh_list()
+
+ def _setup_ui(self) -> None:
+ """Set up dialog UI."""
+ layout = QVBoxLayout(self)
+
+ # Header
+ header = QLabel(f"Repliky vault: {self._vault.manifest.vault_name if self._vault.manifest else 'Vault'}")
+ header.setStyleSheet("font-weight: bold; font-size: 14px;")
+ layout.addWidget(header)
+
+ # Replica table
+ self._table = QTableWidget()
+ self._table.setColumnCount(4)
+ self._table.setHorizontalHeaderLabels(["Cesta", "Typ", "Status", ""])
+ self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
+ self._table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
+ self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
+ self._table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
+ self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
+ self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
+ layout.addWidget(self._table)
+
+ # Buttons
+ btn_layout = QHBoxLayout()
+
+ refresh_btn = QPushButton("Obnovit")
+ refresh_btn.clicked.connect(self._refresh_list)
+ btn_layout.addWidget(refresh_btn)
+
+ btn_layout.addStretch()
+
+ close_btn = QPushButton("Zavřít")
+ close_btn.clicked.connect(self.accept)
+ btn_layout.addWidget(close_btn)
+
+ layout.addLayout(btn_layout)
+
+ def _refresh_list(self) -> None:
+ """Refresh the replica list."""
+ replicas = self._vault.get_replicas()
+ self._table.setRowCount(len(replicas))
+
+ for row, replica in enumerate(replicas):
+ # Path
+ path_item = QTableWidgetItem(str(replica.image_path))
+ path_item.setToolTip(str(replica.image_path))
+ self._table.setItem(row, 0, path_item)
+
+ # Type
+ type_text = "Primární" if replica.is_primary else "Sekundární"
+ type_item = QTableWidgetItem(type_text)
+ if replica.is_primary:
+ type_item.setForeground(Qt.GlobalColor.blue)
+ self._table.setItem(row, 1, type_item)
+
+ # Status
+ if replica.is_mounted:
+ status_text = "Připojeno"
+ status_item = QTableWidgetItem(status_text)
+ status_item.setForeground(Qt.GlobalColor.darkGreen)
+ else:
+ status_text = "Odpojeno"
+ status_item = QTableWidgetItem(status_text)
+ status_item.setForeground(Qt.GlobalColor.red)
+ self._table.setItem(row, 2, status_item)
+
+ # Remove button (only for secondary replicas)
+ if not replica.is_primary:
+ remove_btn = QPushButton("Odebrat")
+ remove_btn.clicked.connect(lambda checked, p=replica.image_path: self._remove_replica(p))
+ self._table.setCellWidget(row, 3, remove_btn)
+ else:
+ self._table.setItem(row, 3, QTableWidgetItem(""))
+
+ def _remove_replica(self, image_path: Path) -> None:
+ """Remove a replica after confirmation."""
+ reply = QMessageBox.question(
+ self,
+ "Odebrat repliku",
+ f"Opravdu chcete odebrat repliku?\n\n{image_path}\n\nSoubor nebude smazán, pouze odpojen.",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ QMessageBox.StandardButton.No,
+ )
+
+ if reply == QMessageBox.StandardButton.Yes:
+ try:
+ self._vault.remove_replica(image_path)
+ self._refresh_list()
+ except Exception as e:
+ QMessageBox.critical(
+ self,
+ "Chyba",
+ f"Nepodařilo se odebrat repliku:\n{e}",
+ )
diff --git a/src/ui/dialogs/new_vault.py b/src/ui/dialogs/new_vault.py
new file mode 100644
index 0000000..9416e23
--- /dev/null
+++ b/src/ui/dialogs/new_vault.py
@@ -0,0 +1,135 @@
+"""New vault creation dialog."""
+
+from pathlib import Path
+
+from PySide6.QtWidgets import (
+ QDialog,
+ QDialogButtonBox,
+ QFileDialog,
+ QFormLayout,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QPushButton,
+ QSpinBox,
+ QVBoxLayout,
+ QWidget,
+)
+
+
+class NewVaultDialog(QDialog):
+ """Dialog for creating a new vault."""
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setWindowTitle("Vytvořit nový vault")
+ self.setMinimumWidth(450)
+ self._setup_ui()
+
+ def _setup_ui(self) -> None:
+ """Set up dialog UI."""
+ layout = QVBoxLayout(self)
+
+ # Form layout for inputs
+ form = QFormLayout()
+
+ # Vault name
+ self._name_edit = QLineEdit()
+ self._name_edit.setPlaceholderText("Můj Vault")
+ form.addRow("Název:", self._name_edit)
+
+ # Path selection
+ path_layout = QHBoxLayout()
+ self._path_edit = QLineEdit()
+ self._path_edit.setPlaceholderText("/home/user/myvault.vault")
+ path_layout.addWidget(self._path_edit)
+
+ browse_btn = QPushButton("Procházet...")
+ browse_btn.clicked.connect(self._browse_path)
+ path_layout.addWidget(browse_btn)
+
+ form.addRow("Cesta:", path_layout)
+
+ # Size
+ size_layout = QHBoxLayout()
+ self._size_spin = QSpinBox()
+ self._size_spin.setMinimum(10)
+ self._size_spin.setMaximum(100000) # 100 GB
+ self._size_spin.setValue(1024) # Default 1 GB
+ self._size_spin.setSuffix(" MB")
+ size_layout.addWidget(self._size_spin)
+
+ # Quick size buttons
+ for size, label in [(100, "100 MB"), (1024, "1 GB"), (10240, "10 GB")]:
+ btn = QPushButton(label)
+ btn.clicked.connect(lambda checked, s=size: self._size_spin.setValue(s))
+ size_layout.addWidget(btn)
+
+ size_layout.addStretch()
+ form.addRow("Velikost:", size_layout)
+
+ layout.addLayout(form)
+
+ # Info label
+ info_label = QLabel(
+ "Vault bude vytvořen jako sparse soubor - zabere místo pouze pro skutečná data."
+ )
+ info_label.setWordWrap(True)
+ info_label.setStyleSheet("color: gray; font-size: 11px;")
+ layout.addWidget(info_label)
+
+ layout.addStretch()
+
+ # Buttons
+ buttons = QDialogButtonBox(
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
+ )
+ buttons.accepted.connect(self._validate_and_accept)
+ buttons.rejected.connect(self.reject)
+ layout.addWidget(buttons)
+
+ def _browse_path(self) -> None:
+ """Open file dialog to select vault path."""
+ path, _ = QFileDialog.getSaveFileName(
+ self,
+ "Vyberte umístění pro vault",
+ str(Path.home()),
+ "Vault soubory (*.vault)",
+ )
+ if path:
+ if not path.endswith(".vault"):
+ path += ".vault"
+ self._path_edit.setText(path)
+
+ # Auto-fill name from filename if empty
+ if not self._name_edit.text():
+ self._name_edit.setText(Path(path).stem)
+
+ def _validate_and_accept(self) -> None:
+ """Validate inputs and accept dialog."""
+ if not self._name_edit.text().strip():
+ self._name_edit.setFocus()
+ return
+
+ if not self._path_edit.text().strip():
+ self._path_edit.setFocus()
+ return
+
+ path = Path(self._path_edit.text())
+ if path.exists():
+ # Could show warning dialog here
+ pass
+
+ self.accept()
+
+ def get_result(self) -> dict:
+ """Get dialog result.
+
+ Returns:
+ Dictionary with vault configuration
+ """
+ return {
+ "name": self._name_edit.text().strip(),
+ "path": self._path_edit.text().strip(),
+ "size_mb": self._size_spin.value(),
+ }
diff --git a/src/ui/dialogs/open_vault.py b/src/ui/dialogs/open_vault.py
new file mode 100644
index 0000000..dc2a419
--- /dev/null
+++ b/src/ui/dialogs/open_vault.py
@@ -0,0 +1,90 @@
+"""Open vault dialog."""
+
+from pathlib import Path
+
+from PySide6.QtWidgets import (
+ QDialog,
+ QDialogButtonBox,
+ QFileDialog,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QPushButton,
+ QVBoxLayout,
+ QWidget,
+)
+
+
+class OpenVaultDialog(QDialog):
+ """Dialog for opening an existing vault."""
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setWindowTitle("Otevřít vault")
+ self.setMinimumWidth(400)
+ self._selected_path: str | None = None
+ self._setup_ui()
+
+ def _setup_ui(self) -> None:
+ """Set up dialog UI."""
+ layout = QVBoxLayout(self)
+
+ # Instructions
+ label = QLabel("Vyberte .vault soubor k otevření:")
+ layout.addWidget(label)
+
+ # Path selection
+ path_layout = QHBoxLayout()
+ self._path_edit = QLineEdit()
+ self._path_edit.setPlaceholderText("/cesta/k/vault.vault")
+ path_layout.addWidget(self._path_edit)
+
+ browse_btn = QPushButton("Procházet...")
+ browse_btn.clicked.connect(self._browse_path)
+ path_layout.addWidget(browse_btn)
+
+ layout.addLayout(path_layout)
+
+ layout.addStretch()
+
+ # Buttons
+ buttons = QDialogButtonBox(
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
+ )
+ buttons.accepted.connect(self._validate_and_accept)
+ buttons.rejected.connect(self.reject)
+ layout.addWidget(buttons)
+
+ def _browse_path(self) -> None:
+ """Open file dialog to select vault file."""
+ path, _ = QFileDialog.getOpenFileName(
+ self,
+ "Vyberte vault soubor",
+ str(Path.home()),
+ "Vault soubory (*.vault);;Všechny soubory (*)",
+ )
+ if path:
+ self._path_edit.setText(path)
+
+ def _validate_and_accept(self) -> None:
+ """Validate inputs and accept dialog."""
+ path = self._path_edit.text().strip()
+ if not path:
+ self._path_edit.setFocus()
+ return
+
+ if not Path(path).exists():
+ # Could show error dialog
+ self._path_edit.setFocus()
+ return
+
+ self._selected_path = path
+ self.accept()
+
+ def get_selected_path(self) -> str | None:
+ """Get selected vault path.
+
+ Returns:
+ Path to selected vault file, or None
+ """
+ return self._selected_path
diff --git a/src/ui/dialogs/resize_vault.py b/src/ui/dialogs/resize_vault.py
new file mode 100644
index 0000000..13ddf6c
--- /dev/null
+++ b/src/ui/dialogs/resize_vault.py
@@ -0,0 +1,106 @@
+"""Resize vault dialog."""
+
+from PySide6.QtWidgets import (
+ QDialog,
+ QDialogButtonBox,
+ QFormLayout,
+ QLabel,
+ QMessageBox,
+ QSpinBox,
+ QVBoxLayout,
+ QWidget,
+)
+
+from src.core.vault import Vault
+
+
+class ResizeVaultDialog(QDialog):
+ """Dialog for resizing a vault."""
+
+ def __init__(self, vault: Vault, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self._vault = vault
+ self.setWindowTitle("Zvětšit vault")
+ self.setMinimumWidth(400)
+ self._new_size: int | None = None
+ self._setup_ui()
+
+ def _setup_ui(self) -> None:
+ """Set up dialog UI."""
+ layout = QVBoxLayout(self)
+
+ # Current size info
+ manifest = self._vault.manifest
+ current_size = manifest.image_size_mb if manifest else 0
+
+ info_label = QLabel(f"Aktuální velikost: {current_size} MB ({current_size / 1024:.1f} GB)")
+ layout.addWidget(info_label)
+
+ # Get actual disk usage if mounted
+ if self._vault.mount_point:
+ import shutil
+ usage = shutil.disk_usage(self._vault.mount_point)
+ used_mb = usage.used // (1024 * 1024)
+ total_mb = usage.total // (1024 * 1024)
+ free_mb = usage.free // (1024 * 1024)
+ usage_label = QLabel(
+ f"Využito: {used_mb} MB / {total_mb} MB (volno: {free_mb} MB)"
+ )
+ layout.addWidget(usage_label)
+
+ # Form for new size
+ form = QFormLayout()
+
+ self._size_spin = QSpinBox()
+ self._size_spin.setMinimum(current_size + 100) # At least 100 MB more
+ self._size_spin.setMaximum(500000) # 500 GB max
+ self._size_spin.setValue(current_size * 2 if current_size > 0 else 2048)
+ self._size_spin.setSuffix(" MB")
+ self._size_spin.setSingleStep(1024)
+ form.addRow("Nová velikost:", self._size_spin)
+
+ layout.addLayout(form)
+
+ # Warning
+ warning_label = QLabel(
+ "Pozor: Zvětšení vault může chvíli trvat a vyžaduje dočasné odpojení.\n"
+ "Velikost lze pouze zvětšit, ne zmenšit."
+ )
+ warning_label.setStyleSheet("color: orange; font-size: 11px;")
+ warning_label.setWordWrap(True)
+ layout.addWidget(warning_label)
+
+ layout.addStretch()
+
+ # Buttons
+ buttons = QDialogButtonBox(
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
+ )
+ buttons.accepted.connect(self._validate_and_accept)
+ buttons.rejected.connect(self.reject)
+ layout.addWidget(buttons)
+
+ def _validate_and_accept(self) -> None:
+ """Validate and accept dialog."""
+ manifest = self._vault.manifest
+ current_size = manifest.image_size_mb if manifest else 0
+ new_size = self._size_spin.value()
+
+ if new_size <= current_size:
+ QMessageBox.warning(
+ self,
+ "Neplatná velikost",
+ "Nová velikost musí být větší než aktuální.",
+ )
+ return
+
+ self._new_size = new_size
+ self.accept()
+
+ def get_new_size(self) -> int | None:
+ """Get the new size selected by user.
+
+ Returns:
+ New size in MB, or None if cancelled
+ """
+ return self._new_size
diff --git a/src/ui/dialogs/sync_progress.py b/src/ui/dialogs/sync_progress.py
new file mode 100644
index 0000000..a363446
--- /dev/null
+++ b/src/ui/dialogs/sync_progress.py
@@ -0,0 +1,104 @@
+"""Sync progress dialog."""
+
+from PySide6.QtCore import Qt, Signal
+from PySide6.QtWidgets import (
+ QDialog,
+ QLabel,
+ QProgressBar,
+ QPushButton,
+ QTextEdit,
+ QVBoxLayout,
+ QWidget,
+)
+
+from src.core.file_sync import CopyProgress
+
+
+class SyncProgressDialog(QDialog):
+ """Dialog showing synchronization progress."""
+
+ # Signal emitted when cancel is requested
+ cancel_requested = Signal()
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setWindowTitle("Synchronizace")
+ self.setMinimumSize(450, 300)
+ self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
+ self._cancelled = False
+ self._setup_ui()
+
+ def _setup_ui(self) -> None:
+ """Set up dialog UI."""
+ layout = QVBoxLayout(self)
+
+ # Status label
+ self._status_label = QLabel("Připravuji synchronizaci...")
+ self._status_label.setStyleSheet("font-weight: bold;")
+ layout.addWidget(self._status_label)
+
+ # Current file label
+ self._file_label = QLabel("")
+ self._file_label.setWordWrap(True)
+ layout.addWidget(self._file_label)
+
+ # Progress bar
+ self._progress_bar = QProgressBar()
+ self._progress_bar.setRange(0, 100)
+ self._progress_bar.setValue(0)
+ layout.addWidget(self._progress_bar)
+
+ # Log area
+ self._log_area = QTextEdit()
+ self._log_area.setReadOnly(True)
+ self._log_area.setMaximumHeight(150)
+ layout.addWidget(self._log_area)
+
+ # Cancel button
+ self._cancel_btn = QPushButton("Zrušit")
+ self._cancel_btn.clicked.connect(self._on_cancel)
+ layout.addWidget(self._cancel_btn)
+
+ def set_status(self, status: str) -> None:
+ """Set the status message."""
+ self._status_label.setText(status)
+
+ def set_current_file(self, file_path: str) -> None:
+ """Set the current file being processed."""
+ self._file_label.setText(f"Soubor: {file_path}")
+
+ def set_progress(self, percent: float) -> None:
+ """Set the progress bar value."""
+ self._progress_bar.setValue(int(percent))
+
+ def add_log(self, message: str) -> None:
+ """Add a message to the log."""
+ self._log_area.append(message)
+
+ def update_from_copy_progress(self, progress: CopyProgress) -> None:
+ """Update dialog from CopyProgress object."""
+ self.set_current_file(str(progress.src_path.name))
+ self.set_progress(progress.percent)
+
+ def set_complete(self, success: bool = True) -> None:
+ """Mark synchronization as complete."""
+ if success:
+ self._status_label.setText("Synchronizace dokončena")
+ self._progress_bar.setValue(100)
+ else:
+ self._status_label.setText("Synchronizace selhala")
+
+ self._cancel_btn.setText("Zavřít")
+ self._cancel_btn.clicked.disconnect()
+ self._cancel_btn.clicked.connect(self.accept)
+
+ def is_cancelled(self) -> bool:
+ """Check if sync was cancelled."""
+ return self._cancelled
+
+ def _on_cancel(self) -> None:
+ """Handle cancel button click."""
+ self._cancelled = True
+ self._status_label.setText("Rušení...")
+ self._cancel_btn.setEnabled(False)
+ self.cancel_requested.emit()
diff --git a/src/ui/notifications.py b/src/ui/notifications.py
new file mode 100644
index 0000000..70e9979
--- /dev/null
+++ b/src/ui/notifications.py
@@ -0,0 +1,50 @@
+"""System notifications for Vault."""
+
+import subprocess
+
+from loguru import logger
+
+
+class NotificationManager:
+ """Manages system notifications."""
+
+ def __init__(self) -> None:
+ self._app_name = "Vault"
+
+ def notify(
+ self,
+ title: str,
+ message: str,
+ critical: bool = False,
+ timeout_ms: int = 5000,
+ ) -> None:
+ """Show a system notification.
+
+ Args:
+ title: Notification title
+ message: Notification message
+ critical: If True, notification is marked as critical/urgent
+ timeout_ms: Timeout in milliseconds (0 = no timeout)
+ """
+ try:
+ # Use notify-send on Linux
+ cmd = [
+ "notify-send",
+ "--app-name", self._app_name,
+ "--expire-time", str(timeout_ms),
+ ]
+
+ if critical:
+ cmd.extend(["--urgency", "critical"])
+
+ cmd.append(title)
+ if message:
+ cmd.append(message)
+
+ subprocess.run(cmd, check=False)
+ logger.debug(f"Notification sent: {title}")
+
+ except FileNotFoundError:
+ logger.warning("notify-send not found, notification not shown")
+ except Exception as e:
+ logger.error(f"Failed to show notification: {e}")
diff --git a/src/ui/tray_app.py b/src/ui/tray_app.py
new file mode 100644
index 0000000..1b24df9
--- /dev/null
+++ b/src/ui/tray_app.py
@@ -0,0 +1,385 @@
+"""System tray application for Vault.
+
+Main entry point for the GUI application.
+"""
+
+import signal
+import subprocess
+import sys
+from pathlib import Path
+
+from loguru import logger
+from PySide6.QtCore import QTimer
+from PySide6.QtGui import QIcon
+from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon
+
+from src.core.sync_manager import SyncStatus
+from src.core.vault import Vault, VaultError, VaultState
+from src.ui.dialogs.new_vault import NewVaultDialog
+from src.ui.dialogs.open_vault import OpenVaultDialog
+from src.ui.notifications import NotificationManager
+
+
+class VaultTrayApp:
+ """System tray application for managing Vault."""
+
+ def __init__(self) -> None:
+ self._app = QApplication(sys.argv)
+ self._app.setQuitOnLastWindowClosed(False)
+ self._app.setApplicationName("Vault")
+
+ self._vault = Vault(
+ on_state_change=self._on_vault_state_change,
+ on_sync_event=lambda e: self._on_sync_event(e),
+ )
+ self._notifications = NotificationManager()
+ self._space_warned = False
+
+ self._tray = QSystemTrayIcon()
+ self._setup_tray()
+
+ # Status check timer
+ self._status_timer = QTimer()
+ self._status_timer.timeout.connect(self._update_status)
+ self._status_timer.start(5000) # Check every 5 seconds
+
+ # Replica availability check timer
+ self._replica_timer = QTimer()
+ self._replica_timer.timeout.connect(self._check_replica_availability)
+ self._replica_timer.start(30000) # Check every 30 seconds
+
+ # Setup signal handlers for graceful shutdown
+ self._setup_signal_handlers()
+
+ def _setup_signal_handlers(self) -> None:
+ """Setup signal handlers for graceful shutdown."""
+ def signal_handler(signum: int, frame: object) -> None:
+ logger.info(f"Received signal {signum}, shutting down gracefully...")
+ self._quit()
+
+ signal.signal(signal.SIGINT, signal_handler)
+ signal.signal(signal.SIGTERM, signal_handler)
+
+ # Use a timer to allow signal processing in Qt event loop
+ self._signal_timer = QTimer()
+ self._signal_timer.timeout.connect(lambda: None) # Keep event loop responsive
+ self._signal_timer.start(500)
+
+ def _setup_tray(self) -> None:
+ """Set up tray icon and menu."""
+ self._update_icon()
+ self._tray.setToolTip("Vault - Resilientní úložiště")
+
+ menu = QMenu()
+
+ # Status header
+ self._status_action = menu.addAction("Žádný vault otevřen")
+ self._status_action.setEnabled(False)
+
+ menu.addSeparator()
+
+ # Open folder action
+ self._open_folder_action = menu.addAction("Otevřít složku")
+ self._open_folder_action.triggered.connect(self._open_folder)
+ self._open_folder_action.setEnabled(False)
+
+ menu.addSeparator()
+
+ # Vault management
+ menu.addAction("Vytvořit nový vault...").triggered.connect(self._new_vault)
+ menu.addAction("Otevřít vault...").triggered.connect(self._open_vault)
+ self._close_action = menu.addAction("Zavřít vault")
+ self._close_action.triggered.connect(self._close_vault)
+ self._close_action.setEnabled(False)
+
+ menu.addSeparator()
+
+ # Replica management
+ self._add_replica_action = menu.addAction("Přidat repliku...")
+ self._add_replica_action.triggered.connect(self._add_replica)
+ self._add_replica_action.setEnabled(False)
+
+ self._manage_replicas_action = menu.addAction("Spravovat repliky...")
+ self._manage_replicas_action.triggered.connect(self._manage_replicas)
+ self._manage_replicas_action.setEnabled(False)
+
+ menu.addSeparator()
+
+ # Sync action
+ self._sync_action = menu.addAction("Synchronizovat")
+ self._sync_action.triggered.connect(self._manual_sync)
+ self._sync_action.setEnabled(False)
+
+ # Resize action
+ self._resize_action = menu.addAction("Zvětšit vault...")
+ self._resize_action.triggered.connect(self._resize_vault)
+ self._resize_action.setEnabled(False)
+
+ menu.addSeparator()
+
+ # Quit
+ menu.addAction("Ukončit").triggered.connect(self._quit)
+
+ self._tray.setContextMenu(menu)
+ self._tray.activated.connect(self._on_tray_activated)
+ self._tray.show()
+
+ def _update_icon(self) -> None:
+ """Update tray icon based on vault state."""
+ # Use built-in icons for now
+ if not self._vault.is_open:
+ # Gray - no vault open
+ icon = QIcon.fromTheme("folder-grey", QIcon.fromTheme("folder"))
+ elif self._vault.sync_status == SyncStatus.SYNCING:
+ # Blue - syncing
+ icon = QIcon.fromTheme("folder-sync", QIcon.fromTheme("folder-download"))
+ elif self._vault.sync_status == SyncStatus.ERROR:
+ # Red - error
+ icon = QIcon.fromTheme("folder-important", QIcon.fromTheme("dialog-error"))
+ elif self._vault.get_unavailable_replicas():
+ # Yellow - some replicas unavailable
+ icon = QIcon.fromTheme("folder-yellow", QIcon.fromTheme("folder-visiting"))
+ else:
+ # Green - all good
+ icon = QIcon.fromTheme("folder-green", QIcon.fromTheme("folder-open"))
+
+ self._tray.setIcon(icon)
+
+ def _update_status(self) -> None:
+ """Update status display."""
+ self._update_icon()
+
+ if not self._vault.is_open:
+ self._status_action.setText("Žádný vault otevřen")
+ self._open_folder_action.setEnabled(False)
+ self._close_action.setEnabled(False)
+ self._add_replica_action.setEnabled(False)
+ self._manage_replicas_action.setEnabled(False)
+ self._sync_action.setEnabled(False)
+ self._resize_action.setEnabled(False)
+ else:
+ manifest = self._vault.manifest
+ name = manifest.vault_name if manifest else "Vault"
+ replicas = self._vault.replica_count
+ unavailable = self._vault.get_unavailable_replicas()
+
+ if unavailable:
+ online = replicas
+ total = replicas + len(unavailable)
+ status_text = f"{name} ({online}/{total} replik online)"
+ else:
+ status_text = f"{name} ({replicas} replik{'a' if replicas == 1 else 'y' if replicas < 5 else ''})"
+
+ if self._vault.sync_status == SyncStatus.SYNCING:
+ status_text += " - synchronizace..."
+
+ self._status_action.setText(status_text)
+ self._open_folder_action.setEnabled(True)
+ self._close_action.setEnabled(True)
+ self._add_replica_action.setEnabled(True)
+ self._manage_replicas_action.setEnabled(True)
+ self._sync_action.setEnabled(True)
+ self._resize_action.setEnabled(True)
+
+ # Check space warning
+ if self._vault.check_space_warning(90.0):
+ space_info = self._vault.get_space_info()
+ if space_info:
+ free_mb = space_info["free"] // (1024 * 1024)
+ if not hasattr(self, "_space_warned") or not self._space_warned:
+ self._notifications.notify(
+ "Vault téměř plný",
+ f"Zbývá pouze {free_mb} MB volného místa",
+ critical=True,
+ )
+ self._space_warned = True
+ else:
+ self._space_warned = False
+
+ def _check_replica_availability(self) -> None:
+ """Check for replica availability and reconnect if possible."""
+ if not self._vault.is_open:
+ return
+
+ unavailable = self._vault.get_unavailable_replicas()
+ if unavailable:
+ # Try to reconnect
+ reconnected = self._vault.reconnect_unavailable_replicas()
+ if reconnected > 0:
+ self._notifications.notify(
+ "Repliky připojeny",
+ f"Připojeno {reconnected} replik{'a' if reconnected == 1 else 'y' if reconnected < 5 else ''}",
+ )
+ self._update_status()
+
+ def _on_vault_state_change(self, state: VaultState) -> None:
+ """Handle vault state change."""
+ logger.debug(f"Vault state changed: {state}")
+ self._update_status()
+
+ if state == VaultState.OPEN:
+ self._notifications.notify(
+ "Vault otevřen",
+ f"Mount point: {self._vault.mount_point}",
+ )
+ elif state == VaultState.CLOSED:
+ self._notifications.notify("Vault zavřen", "")
+ elif state == VaultState.ERROR:
+ self._notifications.notify("Chyba", "Nastala chyba při práci s vault", critical=True)
+
+ def _on_sync_event(self, event: object) -> None:
+ """Handle sync event."""
+ self._update_icon()
+
+ def _on_tray_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
+ """Handle tray icon activation."""
+ if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
+ self._open_folder()
+
+ def _open_folder(self) -> None:
+ """Open vault mount point in file manager."""
+ if not self._vault.is_open or not self._vault.mount_point:
+ return
+
+ try:
+ # Try xdg-open (Linux)
+ subprocess.Popen(["xdg-open", str(self._vault.mount_point)])
+ except Exception as e:
+ logger.error(f"Failed to open folder: {e}")
+ self._notifications.notify("Chyba", f"Nepodařilo se otevřít složku: {e}", critical=True)
+
+ def _new_vault(self) -> None:
+ """Show new vault dialog."""
+ dialog = NewVaultDialog()
+ if dialog.exec():
+ result = dialog.get_result()
+ try:
+ from src.core.image_manager import create_sparse_image
+
+ image_path = Path(result["path"])
+ create_sparse_image(image_path, result["size_mb"])
+
+ mount = self._vault.open(image_path)
+
+ # Update manifest with user-provided name
+ if self._vault.manifest:
+ self._vault.manifest.vault_name = result["name"]
+ self._vault.manifest.image_size_mb = result["size_mb"]
+ self._vault.manifest.save(mount)
+
+ self._update_status()
+ self._open_folder()
+
+ except Exception as e:
+ logger.error(f"Failed to create vault: {e}")
+ self._notifications.notify("Chyba", f"Nepodařilo se vytvořit vault: {e}", critical=True)
+
+ def _open_vault(self) -> None:
+ """Show open vault dialog."""
+ dialog = OpenVaultDialog()
+ if dialog.exec():
+ vault_path = dialog.get_selected_path()
+ if vault_path:
+ try:
+ self._vault.open(Path(vault_path))
+ self._update_status()
+ self._open_folder()
+ except VaultError as e:
+ logger.error(f"Failed to open vault: {e}")
+ self._notifications.notify("Chyba", f"Nepodařilo se otevřít vault: {e}", critical=True)
+
+ def _close_vault(self) -> None:
+ """Close current vault."""
+ if self._vault.is_open:
+ self._vault.close()
+ self._update_status()
+
+ def _add_replica(self) -> None:
+ """Show add replica dialog."""
+ if not self._vault.is_open:
+ return
+
+ from PySide6.QtWidgets import QFileDialog
+
+ path, _ = QFileDialog.getSaveFileName(
+ None,
+ "Vyberte umístění pro novou repliku",
+ "",
+ "Vault soubory (*.vault)",
+ )
+
+ if path:
+ if not path.endswith(".vault"):
+ path += ".vault"
+ try:
+ self._vault.add_replica(Path(path))
+ self._notifications.notify("Replika přidána", f"Nová replika: {path}")
+ self._update_status()
+ except VaultError as e:
+ logger.error(f"Failed to add replica: {e}")
+ self._notifications.notify("Chyba", f"Nepodařilo se přidat repliku: {e}", critical=True)
+
+ def _manage_replicas(self) -> None:
+ """Show replica management dialog."""
+ if not self._vault.is_open:
+ return
+
+ from src.ui.dialogs.manage_replicas import ManageReplicasDialog
+
+ dialog = ManageReplicasDialog(self._vault)
+ dialog.exec()
+ self._update_status()
+
+ def _manual_sync(self) -> None:
+ """Trigger manual synchronization."""
+ if self._vault.is_open:
+ try:
+ self._vault.sync()
+ self._notifications.notify("Synchronizace dokončena", "")
+ except VaultError as e:
+ logger.error(f"Sync failed: {e}")
+ self._notifications.notify("Chyba synchronizace", str(e), critical=True)
+
+ def _resize_vault(self) -> None:
+ """Show resize vault dialog."""
+ if not self._vault.is_open:
+ return
+
+ from src.ui.dialogs.resize_vault import ResizeVaultDialog
+
+ dialog = ResizeVaultDialog(self._vault)
+ if dialog.exec():
+ new_size = dialog.get_new_size()
+ if new_size:
+ try:
+ self._vault.resize(new_size)
+ self._notifications.notify(
+ "Vault zvětšen",
+ f"Nová velikost: {new_size} MB",
+ )
+ self._update_status()
+ except VaultError as e:
+ logger.error(f"Resize failed: {e}")
+ self._notifications.notify("Chyba", f"Nepodařilo se zvětšit vault: {e}", critical=True)
+
+ def _quit(self) -> None:
+ """Quit the application."""
+ if self._vault.is_open:
+ self._vault.close()
+ self._tray.hide()
+ self._app.quit()
+
+ def run(self) -> int:
+ """Run the application."""
+ logger.info("Vault tray application starting...")
+ return self._app.exec()
+
+
+def main() -> int:
+ """Main entry point for tray application."""
+ app = VaultTrayApp()
+ return app.run()
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..d4839a6
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+# Tests package
diff --git a/tests/test_container.py b/tests/test_container.py
new file mode 100644
index 0000000..584fee7
--- /dev/null
+++ b/tests/test_container.py
@@ -0,0 +1,117 @@
+"""Tests for Container class.
+
+Note: These tests require udisks2 to be installed and running.
+They actually mount/unmount images, so they're integration tests.
+"""
+
+from pathlib import Path
+
+import pytest
+
+from src.core.container import Container, ContainerError
+from src.core.image_manager import create_sparse_image
+
+
+@pytest.fixture
+def vault_image(tmp_path: Path) -> Path:
+ """Create a temporary vault image for testing."""
+ image_path = tmp_path / "test.vault"
+ create_sparse_image(image_path, size_mb=10)
+ return image_path
+
+
+class TestContainer:
+ """Tests for Container class."""
+
+ @pytest.mark.integration
+ def test_mount_and_unmount(self, vault_image: Path) -> None:
+ """Test mounting and unmounting a vault image."""
+ container = Container(vault_image)
+
+ # Mount
+ mount_point = container.mount()
+ assert container.is_mounted()
+ assert mount_point.exists()
+ assert mount_point.is_dir()
+
+ # Should be able to write files
+ test_file = mount_point / "test.txt"
+ test_file.write_text("Hello, Vault!")
+ assert test_file.exists()
+
+ # Unmount
+ container.unmount()
+ assert not container.is_mounted()
+
+ @pytest.mark.integration
+ def test_context_manager(self, vault_image: Path) -> None:
+ """Test using container as context manager."""
+ with Container(vault_image) as container:
+ assert container.is_mounted()
+ mount_point = container.mount_point
+ assert mount_point is not None
+ assert mount_point.exists()
+
+ # Should be unmounted after context exits
+ assert not container.is_mounted()
+
+ @pytest.mark.integration
+ def test_mount_creates_vault_directory(self, vault_image: Path) -> None:
+ """Test that .vault directory can be created in mounted image."""
+ with Container(vault_image) as container:
+ vault_dir = container.mount_point / ".vault" # type: ignore
+ vault_dir.mkdir()
+ assert vault_dir.exists()
+
+ # Create manifest file
+ manifest = vault_dir / "manifest.json"
+ manifest.write_text('{"test": true}')
+ assert manifest.exists()
+
+ @pytest.mark.integration
+ def test_mount_already_mounted(self, vault_image: Path) -> None:
+ """Test that mounting already mounted container fails."""
+ container = Container(vault_image)
+ container.mount()
+
+ try:
+ with pytest.raises(ContainerError, match="already mounted"):
+ container.mount()
+ finally:
+ container.unmount()
+
+ def test_mount_nonexistent_image(self, tmp_path: Path) -> None:
+ """Test that mounting nonexistent image fails."""
+ container = Container(tmp_path / "nonexistent.vault")
+
+ with pytest.raises(ContainerError, match="not found"):
+ container.mount()
+
+ def test_is_mounted_initially_false(self, vault_image: Path) -> None:
+ """Test that container is not mounted initially."""
+ container = Container(vault_image)
+ assert not container.is_mounted()
+
+ @pytest.mark.integration
+ def test_unmount_not_mounted(self, vault_image: Path) -> None:
+ """Test that unmounting not mounted container is safe."""
+ container = Container(vault_image)
+
+ # Should not raise
+ container.unmount()
+
+ @pytest.mark.integration
+ def test_data_persists_after_remount(self, vault_image: Path) -> None:
+ """Test that data persists after unmount and remount."""
+ test_content = "Persistent data test"
+
+ # Write data
+ with Container(vault_image) as container:
+ test_file = container.mount_point / "persistent.txt" # type: ignore
+ test_file.write_text(test_content)
+
+ # Read data after remount
+ with Container(vault_image) as container:
+ test_file = container.mount_point / "persistent.txt" # type: ignore
+ assert test_file.exists()
+ assert test_file.read_text() == test_content
diff --git a/tests/test_file_entry.py b/tests/test_file_entry.py
new file mode 100644
index 0000000..d98258a
--- /dev/null
+++ b/tests/test_file_entry.py
@@ -0,0 +1,151 @@
+"""Tests for FileEntry dataclass."""
+
+from datetime import datetime
+from pathlib import Path
+import tempfile
+
+import pytest
+
+from src.core.file_entry import FileEntry
+
+
+class TestFileEntry:
+ """Tests for FileEntry dataclass."""
+
+ def test_create_file_entry(self) -> None:
+ """Test creating a FileEntry instance."""
+ now = datetime.now()
+ entry = FileEntry(
+ path="documents/test.txt",
+ hash="sha256:abc123",
+ size=1024,
+ created=now,
+ modified=now,
+ )
+
+ assert entry.path == "documents/test.txt"
+ assert entry.hash == "sha256:abc123"
+ assert entry.size == 1024
+ assert entry.created == now
+ assert entry.modified == now
+
+ def test_file_entry_is_immutable(self) -> None:
+ """Test that FileEntry is frozen (immutable)."""
+ now = datetime.now()
+ entry = FileEntry(
+ path="test.txt",
+ hash="sha256:abc",
+ size=100,
+ created=now,
+ modified=now,
+ )
+
+ with pytest.raises(AttributeError):
+ entry.path = "other.txt" # type: ignore
+
+ def test_to_dict(self) -> None:
+ """Test serialization to dictionary."""
+ created = datetime(2026, 1, 28, 10, 30, 0)
+ modified = datetime(2026, 1, 28, 14, 20, 0)
+ entry = FileEntry(
+ path="documents/file.txt",
+ hash="sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ size=1234,
+ created=created,
+ modified=modified,
+ )
+
+ result = entry.to_dict()
+
+ assert result == {
+ "path": "documents/file.txt",
+ "hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "size": 1234,
+ "created": "2026-01-28T10:30:00",
+ "modified": "2026-01-28T14:20:00",
+ }
+
+ def test_from_dict(self) -> None:
+ """Test deserialization from dictionary."""
+ data = {
+ "path": "documents/file.txt",
+ "hash": "sha256:abc123",
+ "size": 1234,
+ "created": "2026-01-28T10:30:00",
+ "modified": "2026-01-28T14:20:00",
+ }
+
+ entry = FileEntry.from_dict(data)
+
+ assert entry.path == "documents/file.txt"
+ assert entry.hash == "sha256:abc123"
+ assert entry.size == 1234
+ assert entry.created == datetime(2026, 1, 28, 10, 30, 0)
+ assert entry.modified == datetime(2026, 1, 28, 14, 20, 0)
+
+ def test_from_path(self) -> None:
+ """Test creating FileEntry from actual file."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ base_path = Path(tmpdir)
+ file_path = base_path / "test.txt"
+ file_path.write_text("Hello, World!")
+
+ entry = FileEntry.from_path(base_path, file_path)
+
+ assert entry.path == "test.txt"
+ assert entry.hash.startswith("sha256:")
+ assert entry.size == 13 # len("Hello, World!")
+ assert entry.created is not None
+ assert entry.modified is not None
+
+ def test_from_path_nested_directory(self) -> None:
+ """Test creating FileEntry from file in nested directory."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ base_path = Path(tmpdir)
+ nested_dir = base_path / "documents" / "work"
+ nested_dir.mkdir(parents=True)
+ file_path = nested_dir / "report.txt"
+ file_path.write_text("Test content")
+
+ entry = FileEntry.from_path(base_path, file_path)
+
+ assert entry.path == "documents/work/report.txt"
+
+ def test_has_changed_same_hash(self) -> None:
+ """Test has_changed returns False for same hash."""
+ now = datetime.now()
+ entry1 = FileEntry(
+ path="test.txt", hash="sha256:abc", size=100, created=now, modified=now
+ )
+ entry2 = FileEntry(
+ path="test.txt", hash="sha256:abc", size=100, created=now, modified=now
+ )
+
+ assert not entry1.has_changed(entry2)
+
+ def test_has_changed_different_hash(self) -> None:
+ """Test has_changed returns True for different hash."""
+ now = datetime.now()
+ entry1 = FileEntry(
+ path="test.txt", hash="sha256:abc", size=100, created=now, modified=now
+ )
+ entry2 = FileEntry(
+ path="test.txt", hash="sha256:xyz", size=100, created=now, modified=now
+ )
+
+ assert entry1.has_changed(entry2)
+
+ def test_is_newer_than(self) -> None:
+ """Test is_newer_than comparison."""
+ old_time = datetime(2026, 1, 1, 10, 0, 0)
+ new_time = datetime(2026, 1, 2, 10, 0, 0)
+
+ old_entry = FileEntry(
+ path="test.txt", hash="sha256:abc", size=100, created=old_time, modified=old_time
+ )
+ new_entry = FileEntry(
+ path="test.txt", hash="sha256:xyz", size=100, created=new_time, modified=new_time
+ )
+
+ assert new_entry.is_newer_than(old_entry)
+ assert not old_entry.is_newer_than(new_entry)
diff --git a/tests/test_file_sync.py b/tests/test_file_sync.py
new file mode 100644
index 0000000..2375baa
--- /dev/null
+++ b/tests/test_file_sync.py
@@ -0,0 +1,324 @@
+"""Tests for file_sync module."""
+
+import time
+from pathlib import Path
+
+import pytest
+
+from src.core.file_sync import (
+ CopyProgress,
+ copy_directory_with_progress,
+ copy_file_with_progress,
+ delete_directory,
+ delete_file,
+ move_file,
+ sync_file,
+)
+
+
+class TestCopyProgress:
+ """Tests for CopyProgress dataclass."""
+
+ def test_percent_calculation(self) -> None:
+ progress = CopyProgress(
+ src_path=Path("src"),
+ dst_path=Path("dst"),
+ bytes_copied=50,
+ total_bytes=100,
+ )
+ assert progress.percent == 50.0
+
+ def test_percent_with_zero_total(self) -> None:
+ progress = CopyProgress(
+ src_path=Path("src"),
+ dst_path=Path("dst"),
+ bytes_copied=0,
+ total_bytes=0,
+ )
+ assert progress.percent == 100.0
+
+ def test_is_complete_true(self) -> None:
+ progress = CopyProgress(
+ src_path=Path("src"),
+ dst_path=Path("dst"),
+ bytes_copied=100,
+ total_bytes=100,
+ )
+ assert progress.is_complete is True
+
+ def test_is_complete_false(self) -> None:
+ progress = CopyProgress(
+ src_path=Path("src"),
+ dst_path=Path("dst"),
+ bytes_copied=50,
+ total_bytes=100,
+ )
+ assert progress.is_complete is False
+
+
+class TestCopyFileWithProgress:
+ """Tests for copy_file_with_progress function."""
+
+ def test_copy_file(self, tmp_path: Path) -> None:
+ src = tmp_path / "source.txt"
+ dst = tmp_path / "dest.txt"
+ src.write_text("Hello, World!")
+
+ copy_file_with_progress(src, dst)
+
+ assert dst.exists()
+ assert dst.read_text() == "Hello, World!"
+
+ def test_copy_file_creates_parent_dirs(self, tmp_path: Path) -> None:
+ src = tmp_path / "source.txt"
+ dst = tmp_path / "nested" / "deep" / "dest.txt"
+ src.write_text("content")
+
+ copy_file_with_progress(src, dst)
+
+ assert dst.exists()
+ assert dst.read_text() == "content"
+
+ def test_copy_file_with_callback(self, tmp_path: Path) -> None:
+ src = tmp_path / "source.txt"
+ dst = tmp_path / "dest.txt"
+ # Create a file larger than chunk size
+ content = "x" * 5000
+ src.write_text(content)
+
+ progress_calls: list[CopyProgress] = []
+
+ def callback(progress: CopyProgress) -> None:
+ progress_calls.append(progress)
+
+ copy_file_with_progress(src, dst, callback=callback, chunk_size=1000)
+
+ assert len(progress_calls) >= 1
+ assert progress_calls[-1].is_complete
+
+ def test_copy_file_preserves_timestamps(self, tmp_path: Path) -> None:
+ src = tmp_path / "source.txt"
+ dst = tmp_path / "dest.txt"
+ src.write_text("content")
+
+ # Get original timestamps
+ src_stat = src.stat()
+
+ copy_file_with_progress(src, dst)
+
+ dst_stat = dst.stat()
+ assert abs(src_stat.st_mtime - dst_stat.st_mtime) < 1
+
+ def test_copy_file_source_not_found(self, tmp_path: Path) -> None:
+ src = tmp_path / "nonexistent.txt"
+ dst = tmp_path / "dest.txt"
+
+ with pytest.raises(FileNotFoundError):
+ copy_file_with_progress(src, dst)
+
+ def test_copy_file_source_is_directory(self, tmp_path: Path) -> None:
+ src = tmp_path / "srcdir"
+ dst = tmp_path / "dest.txt"
+ src.mkdir()
+
+ with pytest.raises(IsADirectoryError):
+ copy_file_with_progress(src, dst)
+
+ def test_copy_file_destination_is_directory(self, tmp_path: Path) -> None:
+ src = tmp_path / "source.txt"
+ dst = tmp_path / "dstdir"
+ src.write_text("content")
+ dst.mkdir()
+
+ with pytest.raises(IsADirectoryError):
+ copy_file_with_progress(src, dst)
+
+
+class TestCopyDirectoryWithProgress:
+ """Tests for copy_directory_with_progress function."""
+
+ def test_copy_directory(self, tmp_path: Path) -> None:
+ src = tmp_path / "srcdir"
+ dst = tmp_path / "dstdir"
+ src.mkdir()
+ (src / "file1.txt").write_text("content1")
+ (src / "file2.txt").write_text("content2")
+
+ copy_directory_with_progress(src, dst)
+
+ assert dst.exists()
+ assert (dst / "file1.txt").read_text() == "content1"
+ assert (dst / "file2.txt").read_text() == "content2"
+
+ def test_copy_nested_directory(self, tmp_path: Path) -> None:
+ src = tmp_path / "srcdir"
+ dst = tmp_path / "dstdir"
+ src.mkdir()
+ (src / "nested").mkdir()
+ (src / "nested" / "deep.txt").write_text("deep content")
+
+ copy_directory_with_progress(src, dst)
+
+ assert (dst / "nested" / "deep.txt").read_text() == "deep content"
+
+ def test_copy_directory_not_found(self, tmp_path: Path) -> None:
+ src = tmp_path / "nonexistent"
+ dst = tmp_path / "dstdir"
+
+ with pytest.raises(FileNotFoundError):
+ copy_directory_with_progress(src, dst)
+
+ def test_copy_directory_source_is_file(self, tmp_path: Path) -> None:
+ src = tmp_path / "source.txt"
+ dst = tmp_path / "dstdir"
+ src.write_text("content")
+
+ with pytest.raises(NotADirectoryError):
+ copy_directory_with_progress(src, dst)
+
+
+class TestDeleteFile:
+ """Tests for delete_file function."""
+
+ def test_delete_file(self, tmp_path: Path) -> None:
+ file = tmp_path / "test.txt"
+ file.write_text("content")
+
+ delete_file(file)
+
+ assert not file.exists()
+
+ def test_delete_file_not_found(self, tmp_path: Path) -> None:
+ file = tmp_path / "nonexistent.txt"
+
+ with pytest.raises(FileNotFoundError):
+ delete_file(file)
+
+ def test_delete_file_is_directory(self, tmp_path: Path) -> None:
+ dir_path = tmp_path / "testdir"
+ dir_path.mkdir()
+
+ with pytest.raises(IsADirectoryError):
+ delete_file(dir_path)
+
+
+class TestDeleteDirectory:
+ """Tests for delete_directory function."""
+
+ def test_delete_directory(self, tmp_path: Path) -> None:
+ dir_path = tmp_path / "testdir"
+ dir_path.mkdir()
+ (dir_path / "file.txt").write_text("content")
+
+ delete_directory(dir_path)
+
+ assert not dir_path.exists()
+
+ def test_delete_nested_directory(self, tmp_path: Path) -> None:
+ dir_path = tmp_path / "testdir"
+ dir_path.mkdir()
+ (dir_path / "nested").mkdir()
+ (dir_path / "nested" / "deep.txt").write_text("content")
+
+ delete_directory(dir_path)
+
+ assert not dir_path.exists()
+
+ def test_delete_directory_not_found(self, tmp_path: Path) -> None:
+ dir_path = tmp_path / "nonexistent"
+
+ with pytest.raises(FileNotFoundError):
+ delete_directory(dir_path)
+
+ def test_delete_directory_is_file(self, tmp_path: Path) -> None:
+ file = tmp_path / "test.txt"
+ file.write_text("content")
+
+ with pytest.raises(NotADirectoryError):
+ delete_directory(file)
+
+
+class TestMoveFile:
+ """Tests for move_file function."""
+
+ def test_move_file(self, tmp_path: Path) -> None:
+ src = tmp_path / "source.txt"
+ dst = tmp_path / "dest.txt"
+ src.write_text("content")
+
+ move_file(src, dst)
+
+ assert not src.exists()
+ assert dst.exists()
+ assert dst.read_text() == "content"
+
+ def test_move_file_creates_parent_dirs(self, tmp_path: Path) -> None:
+ src = tmp_path / "source.txt"
+ dst = tmp_path / "nested" / "deep" / "dest.txt"
+ src.write_text("content")
+
+ move_file(src, dst)
+
+ assert not src.exists()
+ assert dst.exists()
+
+ def test_move_file_not_found(self, tmp_path: Path) -> None:
+ src = tmp_path / "nonexistent.txt"
+ dst = tmp_path / "dest.txt"
+
+ with pytest.raises(FileNotFoundError):
+ move_file(src, dst)
+
+
+class TestSyncFile:
+ """Tests for sync_file function."""
+
+ def test_sync_new_file(self, tmp_path: Path) -> None:
+ src = tmp_path / "source.txt"
+ dst = tmp_path / "dest.txt"
+ src.write_text("content")
+
+ result = sync_file(src, dst)
+
+ assert result is True
+ assert dst.exists()
+ assert dst.read_text() == "content"
+
+ def test_sync_newer_source(self, tmp_path: Path) -> None:
+ src = tmp_path / "source.txt"
+ dst = tmp_path / "dest.txt"
+
+ # Create destination first
+ dst.write_text("old content")
+ time.sleep(0.1)
+
+ # Create newer source
+ src.write_text("new content")
+
+ result = sync_file(src, dst)
+
+ assert result is True
+ assert dst.read_text() == "new content"
+
+ def test_sync_older_source(self, tmp_path: Path) -> None:
+ src = tmp_path / "source.txt"
+ dst = tmp_path / "dest.txt"
+
+ # Create source first
+ src.write_text("old content")
+ time.sleep(0.1)
+
+ # Create newer destination
+ dst.write_text("new content")
+
+ result = sync_file(src, dst)
+
+ assert result is False
+ assert dst.read_text() == "new content"
+
+ def test_sync_file_not_found(self, tmp_path: Path) -> None:
+ src = tmp_path / "nonexistent.txt"
+ dst = tmp_path / "dest.txt"
+
+ with pytest.raises(FileNotFoundError):
+ sync_file(src, dst)
diff --git a/tests/test_file_watcher.py b/tests/test_file_watcher.py
new file mode 100644
index 0000000..a91b02a
--- /dev/null
+++ b/tests/test_file_watcher.py
@@ -0,0 +1,242 @@
+"""Tests for file_watcher module."""
+
+import time
+from pathlib import Path
+from threading import Event
+
+import pytest
+
+from src.core.file_watcher import EventType, FileEvent, FileWatcher
+
+
+class TestFileEvent:
+ """Tests for FileEvent dataclass."""
+
+ def test_create_file_event(self) -> None:
+ event = FileEvent(
+ event_type=EventType.CREATED,
+ path="test.txt",
+ is_directory=False,
+ )
+ assert event.event_type == EventType.CREATED
+ assert event.path == "test.txt"
+ assert event.is_directory is False
+ assert event.dest_path is None
+
+ def test_move_event_with_dest(self) -> None:
+ event = FileEvent(
+ event_type=EventType.MOVED,
+ path="old.txt",
+ is_directory=False,
+ dest_path="new.txt",
+ )
+ assert event.event_type == EventType.MOVED
+ assert event.path == "old.txt"
+ assert event.dest_path == "new.txt"
+
+ def test_str_representation(self) -> None:
+ event = FileEvent(EventType.CREATED, "test.txt", False)
+ assert str(event) == "created: test.txt"
+
+ def test_str_representation_moved(self) -> None:
+ event = FileEvent(EventType.MOVED, "old.txt", False, "new.txt")
+ assert str(event) == "moved: old.txt -> new.txt"
+
+
+class TestFileWatcher:
+ """Tests for FileWatcher class."""
+
+ def test_start_and_stop(self, tmp_path: Path) -> None:
+ events: list[FileEvent] = []
+ watcher = FileWatcher(tmp_path, callback=events.append)
+
+ assert not watcher.is_running()
+ watcher.start()
+ assert watcher.is_running()
+ watcher.stop()
+ assert not watcher.is_running()
+
+ def test_context_manager(self, tmp_path: Path) -> None:
+ events: list[FileEvent] = []
+
+ with FileWatcher(tmp_path, callback=events.append) as watcher:
+ assert watcher.is_running()
+
+ assert not watcher.is_running()
+
+ def test_start_nonexistent_path_raises(self, tmp_path: Path) -> None:
+ nonexistent = tmp_path / "nonexistent"
+ events: list[FileEvent] = []
+ watcher = FileWatcher(nonexistent, callback=events.append)
+
+ with pytest.raises(FileNotFoundError):
+ watcher.start()
+
+ def test_double_start_is_safe(self, tmp_path: Path) -> None:
+ events: list[FileEvent] = []
+ watcher = FileWatcher(tmp_path, callback=events.append)
+
+ watcher.start()
+ watcher.start() # Should not raise
+ assert watcher.is_running()
+ watcher.stop()
+
+ def test_double_stop_is_safe(self, tmp_path: Path) -> None:
+ events: list[FileEvent] = []
+ watcher = FileWatcher(tmp_path, callback=events.append)
+
+ watcher.start()
+ watcher.stop()
+ watcher.stop() # Should not raise
+ assert not watcher.is_running()
+
+ def test_detects_file_creation(self, tmp_path: Path) -> None:
+ events: list[FileEvent] = []
+ event_received = Event()
+
+ def callback(event: FileEvent) -> None:
+ events.append(event)
+ event_received.set()
+
+ with FileWatcher(tmp_path, callback=callback):
+ # Create a file
+ test_file = tmp_path / "test.txt"
+ test_file.write_text("hello")
+
+ # Wait for event
+ event_received.wait(timeout=2.0)
+
+ # Check that we got a CREATED event
+ created_events = [e for e in events if e.event_type == EventType.CREATED]
+ assert len(created_events) >= 1
+ assert any(e.path == "test.txt" for e in created_events)
+
+ def test_detects_file_deletion(self, tmp_path: Path) -> None:
+ # Create file first
+ test_file = tmp_path / "test.txt"
+ test_file.write_text("hello")
+
+ events: list[FileEvent] = []
+ event_received = Event()
+
+ def callback(event: FileEvent) -> None:
+ events.append(event)
+ if event.event_type == EventType.DELETED:
+ event_received.set()
+
+ with FileWatcher(tmp_path, callback=callback):
+ # Delete the file
+ test_file.unlink()
+
+ # Wait for event
+ event_received.wait(timeout=2.0)
+
+ # Check that we got a DELETED event
+ deleted_events = [e for e in events if e.event_type == EventType.DELETED]
+ assert len(deleted_events) >= 1
+ assert any(e.path == "test.txt" for e in deleted_events)
+
+ def test_detects_file_move(self, tmp_path: Path) -> None:
+ # Create file first
+ test_file = tmp_path / "old.txt"
+ test_file.write_text("hello")
+
+ events: list[FileEvent] = []
+ event_received = Event()
+
+ def callback(event: FileEvent) -> None:
+ events.append(event)
+ if event.event_type == EventType.MOVED:
+ event_received.set()
+
+ with FileWatcher(tmp_path, callback=callback):
+ # Move the file
+ new_file = tmp_path / "new.txt"
+ test_file.rename(new_file)
+
+ # Wait for event
+ event_received.wait(timeout=2.0)
+
+ # Check that we got a MOVED event
+ moved_events = [e for e in events if e.event_type == EventType.MOVED]
+ assert len(moved_events) >= 1
+ assert any(e.path == "old.txt" and e.dest_path == "new.txt" for e in moved_events)
+
+ def test_ignores_vault_directory(self, tmp_path: Path) -> None:
+ # Create .vault directory
+ vault_dir = tmp_path / ".vault"
+ vault_dir.mkdir()
+
+ events: list[FileEvent] = []
+
+ with FileWatcher(tmp_path, callback=events.append):
+ # Create file inside .vault
+ (vault_dir / "manifest.json").write_text("{}")
+ time.sleep(0.5)
+
+ # No events should be recorded for .vault directory
+ assert all(".vault" not in e.path for e in events)
+
+ def test_custom_ignore_patterns(self, tmp_path: Path) -> None:
+ events: list[FileEvent] = []
+ event_received = Event()
+
+ def callback(event: FileEvent) -> None:
+ events.append(event)
+ event_received.set()
+
+ with FileWatcher(tmp_path, callback=callback, ignore_patterns=[".vault", "__pycache__"]):
+ # Create ignored directory
+ cache_dir = tmp_path / "__pycache__"
+ cache_dir.mkdir()
+ (cache_dir / "test.pyc").write_text("cached")
+ time.sleep(0.2)
+
+ # Create non-ignored file
+ (tmp_path / "regular.txt").write_text("hello")
+ event_received.wait(timeout=2.0)
+
+ # Only regular.txt events should be recorded
+ assert all("__pycache__" not in e.path for e in events)
+ assert any("regular.txt" in e.path for e in events)
+
+ def test_detects_nested_file_creation(self, tmp_path: Path) -> None:
+ # Create nested directory
+ nested = tmp_path / "subdir" / "nested"
+ nested.mkdir(parents=True)
+
+ events: list[FileEvent] = []
+ event_received = Event()
+
+ def callback(event: FileEvent) -> None:
+ events.append(event)
+ if event.event_type == EventType.CREATED and "deep.txt" in event.path:
+ event_received.set()
+
+ with FileWatcher(tmp_path, callback=callback):
+ # Create file in nested directory
+ (nested / "deep.txt").write_text("nested content")
+ event_received.wait(timeout=2.0)
+
+ # Check event has correct relative path
+ created_events = [e for e in events if e.event_type == EventType.CREATED]
+ assert any("subdir/nested/deep.txt" in e.path or "subdir\\nested\\deep.txt" in e.path for e in created_events)
+
+ def test_detects_directory_creation(self, tmp_path: Path) -> None:
+ events: list[FileEvent] = []
+ event_received = Event()
+
+ def callback(event: FileEvent) -> None:
+ events.append(event)
+ if event.is_directory and event.event_type == EventType.CREATED:
+ event_received.set()
+
+ with FileWatcher(tmp_path, callback=callback):
+ # Create directory
+ (tmp_path / "newdir").mkdir()
+ event_received.wait(timeout=2.0)
+
+ # Check directory creation event
+ dir_events = [e for e in events if e.is_directory and e.event_type == EventType.CREATED]
+ assert len(dir_events) >= 1
+ assert any(e.path == "newdir" for e in dir_events)
diff --git a/tests/test_image_manager.py b/tests/test_image_manager.py
new file mode 100644
index 0000000..4bb6407
--- /dev/null
+++ b/tests/test_image_manager.py
@@ -0,0 +1,147 @@
+"""Tests for image_manager module."""
+
+import tempfile
+from pathlib import Path
+
+import pytest
+
+from src.core.image_manager import (
+ ImageError,
+ create_sparse_image,
+ delete_image,
+ get_image_info,
+ resize_image,
+)
+
+
+class TestCreateSparseImage:
+ """Tests for create_sparse_image function."""
+
+ def test_create_sparse_image(self) -> None:
+ """Test creating a sparse image."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = Path(tmpdir) / "test.vault"
+
+ create_sparse_image(path, size_mb=10)
+
+ assert path.exists()
+ # File should be 10MB in logical size
+ assert path.stat().st_size == 10 * 1024 * 1024
+
+ def test_create_sparse_image_is_sparse(self) -> None:
+ """Test that created image is actually sparse (uses less disk space)."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = Path(tmpdir) / "test.vault"
+
+ create_sparse_image(path, size_mb=100)
+
+ stat = path.stat()
+ # Actual disk usage should be much less than logical size
+ # st_blocks is in 512-byte units
+ actual_size = stat.st_blocks * 512
+ logical_size = stat.st_size
+
+ # Actual size should be less than 10% of logical size for a sparse file
+ # (exFAT metadata takes some space, so not 0)
+ assert actual_size < logical_size * 0.1
+
+ def test_create_sparse_image_already_exists(self) -> None:
+ """Test that creating image fails if file exists."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = Path(tmpdir) / "test.vault"
+ path.touch()
+
+ with pytest.raises(ImageError, match="already exists"):
+ create_sparse_image(path, size_mb=10)
+
+ def test_create_sparse_image_invalid_path(self) -> None:
+ """Test that creating image fails for invalid path."""
+ path = Path("/nonexistent/directory/test.vault")
+
+ with pytest.raises(ImageError):
+ create_sparse_image(path, size_mb=10)
+
+
+class TestResizeImage:
+ """Tests for resize_image function."""
+
+ def test_resize_image(self) -> None:
+ """Test resizing an image."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = Path(tmpdir) / "test.vault"
+ create_sparse_image(path, size_mb=10)
+
+ resize_image(path, new_size_mb=20)
+
+ assert path.stat().st_size == 20 * 1024 * 1024
+
+ def test_resize_image_smaller_fails(self) -> None:
+ """Test that shrinking an image fails."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = Path(tmpdir) / "test.vault"
+ create_sparse_image(path, size_mb=20)
+
+ with pytest.raises(ImageError, match="must be larger"):
+ resize_image(path, new_size_mb=10)
+
+ def test_resize_image_same_size_fails(self) -> None:
+ """Test that resizing to same size fails."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = Path(tmpdir) / "test.vault"
+ create_sparse_image(path, size_mb=10)
+
+ with pytest.raises(ImageError, match="must be larger"):
+ resize_image(path, new_size_mb=10)
+
+ def test_resize_nonexistent_image(self) -> None:
+ """Test that resizing nonexistent image fails."""
+ path = Path("/nonexistent/test.vault")
+
+ with pytest.raises(ImageError, match="not found"):
+ resize_image(path, new_size_mb=20)
+
+
+class TestGetImageInfo:
+ """Tests for get_image_info function."""
+
+ def test_get_image_info(self) -> None:
+ """Test getting image info."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = Path(tmpdir) / "test.vault"
+ create_sparse_image(path, size_mb=50)
+
+ info = get_image_info(path)
+
+ assert info["path"] == str(path)
+ assert info["size_mb"] == 50
+ assert info["actual_size_mb"] < 50 # Sparse file
+ assert 0 < info["sparse_ratio"] < 1
+
+ def test_get_image_info_nonexistent(self) -> None:
+ """Test getting info for nonexistent image."""
+ path = Path("/nonexistent/test.vault")
+
+ with pytest.raises(ImageError, match="not found"):
+ get_image_info(path)
+
+
+class TestDeleteImage:
+ """Tests for delete_image function."""
+
+ def test_delete_image(self) -> None:
+ """Test deleting an image."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = Path(tmpdir) / "test.vault"
+ create_sparse_image(path, size_mb=10)
+ assert path.exists()
+
+ delete_image(path)
+
+ assert not path.exists()
+
+ def test_delete_nonexistent_image(self) -> None:
+ """Test deleting nonexistent image fails."""
+ path = Path("/nonexistent/test.vault")
+
+ with pytest.raises(ImageError, match="not found"):
+ delete_image(path)
diff --git a/tests/test_lock.py b/tests/test_lock.py
new file mode 100644
index 0000000..3cedf08
--- /dev/null
+++ b/tests/test_lock.py
@@ -0,0 +1,182 @@
+"""Tests for VaultLock."""
+
+import multiprocessing
+import os
+import tempfile
+import time
+from pathlib import Path
+
+import pytest
+
+from src.core.lock import VaultLock, VaultLockError
+
+
+class TestVaultLock:
+ """Tests for VaultLock class."""
+
+ def test_acquire_and_release(self) -> None:
+ """Test basic lock acquire and release."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ lock_path = Path(tmpdir) / ".vault" / "lock"
+ lock = VaultLock(lock_path)
+
+ assert lock.acquire()
+ assert lock_path.exists()
+ lock.release()
+
+ def test_lock_creates_directory(self) -> None:
+ """Test that lock creates parent directory if needed."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ lock_path = Path(tmpdir) / "nested" / "dir" / "lock"
+ lock = VaultLock(lock_path)
+
+ assert lock.acquire()
+ assert lock_path.parent.exists()
+ lock.release()
+
+ def test_lock_writes_pid(self) -> None:
+ """Test that lock file contains PID."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ lock_path = Path(tmpdir) / "lock"
+ lock = VaultLock(lock_path)
+
+ lock.acquire()
+ pid = lock.get_owner_pid()
+ lock.release()
+
+ assert pid == os.getpid()
+
+ def test_release_removes_lock_file(self) -> None:
+ """Test that release removes lock file."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ lock_path = Path(tmpdir) / "lock"
+ lock = VaultLock(lock_path)
+
+ lock.acquire()
+ lock.release()
+
+ assert not lock_path.exists()
+
+ def test_release_safe_when_not_locked(self) -> None:
+ """Test that release is safe to call when not locked."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ lock_path = Path(tmpdir) / "lock"
+ lock = VaultLock(lock_path)
+
+ # Should not raise
+ lock.release()
+
+ def test_is_locked_when_not_locked(self) -> None:
+ """Test is_locked returns False when not locked."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ lock_path = Path(tmpdir) / "lock"
+ lock = VaultLock(lock_path)
+
+ assert not lock.is_locked()
+
+ def test_is_locked_when_locked(self) -> None:
+ """Test is_locked returns True when locked."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ lock_path = Path(tmpdir) / "lock"
+ lock = VaultLock(lock_path)
+
+ lock.acquire()
+ try:
+ # Check from different VaultLock instance
+ other_lock = VaultLock(lock_path)
+ assert other_lock.is_locked()
+ finally:
+ lock.release()
+
+ def test_context_manager(self) -> None:
+ """Test context manager usage."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ lock_path = Path(tmpdir) / "lock"
+
+ with VaultLock(lock_path):
+ assert lock_path.exists()
+
+ assert not lock_path.exists()
+
+ def test_context_manager_raises_when_locked(self) -> None:
+ """Test context manager raises when already locked."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ lock_path = Path(tmpdir) / "lock"
+ lock1 = VaultLock(lock_path)
+ lock1.acquire()
+
+ try:
+ with pytest.raises(VaultLockError):
+ with VaultLock(lock_path):
+ pass
+ finally:
+ lock1.release()
+
+ def test_get_owner_pid_no_lock_file(self) -> None:
+ """Test get_owner_pid returns None when no lock file."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ lock_path = Path(tmpdir) / "lock"
+ lock = VaultLock(lock_path)
+
+ assert lock.get_owner_pid() is None
+
+
+def _acquire_lock_in_subprocess(lock_path: str, result_queue: multiprocessing.Queue) -> None:
+ """Helper function to acquire lock in subprocess."""
+ lock = VaultLock(Path(lock_path))
+ acquired = lock.acquire()
+ result_queue.put(acquired)
+ if acquired:
+ time.sleep(0.5) # Hold lock briefly
+ lock.release()
+
+
+class TestVaultLockMultiprocess:
+ """Tests for VaultLock with multiple processes."""
+
+ def test_second_process_cannot_acquire(self) -> None:
+ """Test that second process cannot acquire lock."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ lock_path = Path(tmpdir) / "lock"
+ lock = VaultLock(lock_path)
+
+ # Acquire lock in main process
+ assert lock.acquire()
+
+ try:
+ # Try to acquire in subprocess
+ result_queue: multiprocessing.Queue = multiprocessing.Queue()
+ process = multiprocessing.Process(
+ target=_acquire_lock_in_subprocess,
+ args=(str(lock_path), result_queue),
+ )
+ process.start()
+ process.join(timeout=2)
+
+ # Subprocess should not have acquired the lock
+ acquired_in_subprocess = result_queue.get(timeout=1)
+ assert not acquired_in_subprocess
+ finally:
+ lock.release()
+
+ def test_process_can_acquire_after_release(self) -> None:
+ """Test that process can acquire lock after it's released."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ lock_path = Path(tmpdir) / "lock"
+ lock = VaultLock(lock_path)
+
+ # Acquire and release
+ lock.acquire()
+ lock.release()
+
+ # Now subprocess should be able to acquire
+ result_queue: multiprocessing.Queue = multiprocessing.Queue()
+ process = multiprocessing.Process(
+ target=_acquire_lock_in_subprocess,
+ args=(str(lock_path), result_queue),
+ )
+ process.start()
+ process.join(timeout=2)
+
+ acquired_in_subprocess = result_queue.get(timeout=1)
+ assert acquired_in_subprocess
diff --git a/tests/test_manifest.py b/tests/test_manifest.py
new file mode 100644
index 0000000..66b5b42
--- /dev/null
+++ b/tests/test_manifest.py
@@ -0,0 +1,266 @@
+"""Tests for Manifest dataclass."""
+
+import tempfile
+from datetime import datetime
+from pathlib import Path
+
+from src.core.file_entry import FileEntry
+from src.core.manifest import Location, Manifest
+
+
+class TestLocation:
+ """Tests for Location dataclass."""
+
+ def test_create_location(self) -> None:
+ """Test creating a Location instance."""
+ now = datetime.now()
+ loc = Location(
+ path="/mnt/disk1/vault.vault",
+ last_seen=now,
+ status="active",
+ )
+
+ assert loc.path == "/mnt/disk1/vault.vault"
+ assert loc.last_seen == now
+ assert loc.status == "active"
+
+ def test_to_dict(self) -> None:
+ """Test serialization to dictionary."""
+ time = datetime(2026, 1, 28, 15, 45, 0)
+ loc = Location(
+ path="/mnt/disk1/vault.vault",
+ last_seen=time,
+ status="active",
+ )
+
+ result = loc.to_dict()
+
+ assert result == {
+ "path": "/mnt/disk1/vault.vault",
+ "last_seen": "2026-01-28T15:45:00",
+ "status": "active",
+ }
+
+ def test_from_dict(self) -> None:
+ """Test deserialization from dictionary."""
+ data = {
+ "path": "/mnt/nas/vault.vault",
+ "last_seen": "2026-01-25T08:00:00",
+ "status": "unreachable",
+ }
+
+ loc = Location.from_dict(data)
+
+ assert loc.path == "/mnt/nas/vault.vault"
+ assert loc.last_seen == datetime(2026, 1, 25, 8, 0, 0)
+ assert loc.status == "unreachable"
+
+
+class TestManifest:
+ """Tests for Manifest dataclass."""
+
+ def test_create_new_manifest(self) -> None:
+ """Test creating a new manifest."""
+ manifest = Manifest.create_new(
+ vault_name="My Vault",
+ image_size_mb=1024,
+ location_path="/mnt/disk1/myvault.vault",
+ )
+
+ assert manifest.vault_name == "My Vault"
+ assert manifest.image_size_mb == 1024
+ assert manifest.version == 1
+ assert len(manifest.locations) == 1
+ assert manifest.locations[0].path == "/mnt/disk1/myvault.vault"
+ assert manifest.locations[0].status == "active"
+ assert len(manifest.files) == 0
+ assert manifest.vault_id # UUID should be set
+
+ def test_to_dict(self) -> None:
+ """Test serialization to dictionary."""
+ manifest = Manifest.create_new(
+ vault_name="Test Vault",
+ image_size_mb=512,
+ location_path="/test/vault.vault",
+ )
+
+ result = manifest.to_dict()
+
+ assert result["vault_name"] == "Test Vault"
+ assert result["image_size_mb"] == 512
+ assert result["version"] == 1
+ assert len(result["locations"]) == 1
+ assert len(result["files"]) == 0
+ assert "vault_id" in result
+ assert "created" in result
+ assert "last_modified" in result
+
+ def test_from_dict(self) -> None:
+ """Test deserialization from dictionary."""
+ data = {
+ "vault_id": "550e8400-e29b-41d4-a716-446655440000",
+ "vault_name": "My Vault",
+ "version": 1,
+ "created": "2026-01-28T10:30:00",
+ "last_modified": "2026-01-28T15:45:00",
+ "image_size_mb": 10240,
+ "locations": [
+ {
+ "path": "/mnt/disk1/myvault.vault",
+ "last_seen": "2026-01-28T15:45:00",
+ "status": "active",
+ }
+ ],
+ "files": [
+ {
+ "path": "documents/file.txt",
+ "hash": "sha256:abc123",
+ "size": 1234,
+ "created": "2026-01-28T10:30:00",
+ "modified": "2026-01-28T14:20:00",
+ }
+ ],
+ }
+
+ manifest = Manifest.from_dict(data)
+
+ assert manifest.vault_id == "550e8400-e29b-41d4-a716-446655440000"
+ assert manifest.vault_name == "My Vault"
+ assert manifest.image_size_mb == 10240
+ assert len(manifest.locations) == 1
+ assert len(manifest.files) == 1
+ assert manifest.files[0].path == "documents/file.txt"
+
+ def test_save_and_load(self) -> None:
+ """Test saving and loading manifest to/from file."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ mount_point = Path(tmpdir)
+
+ # Create and save
+ manifest = Manifest.create_new(
+ vault_name="Test Vault",
+ image_size_mb=512,
+ location_path="/test/vault.vault",
+ )
+ manifest.save(mount_point)
+
+ # Verify file exists
+ manifest_path = mount_point / ".vault" / "manifest.json"
+ assert manifest_path.exists()
+
+ # Load and verify
+ loaded = Manifest.load(mount_point)
+ assert loaded.vault_id == manifest.vault_id
+ assert loaded.vault_name == manifest.vault_name
+ assert loaded.image_size_mb == manifest.image_size_mb
+
+ def test_add_location(self) -> None:
+ """Test adding a new location."""
+ manifest = Manifest.create_new(
+ vault_name="Test",
+ image_size_mb=512,
+ location_path="/disk1/vault.vault",
+ )
+ original_modified = manifest.last_modified
+
+ manifest.add_location("/disk2/vault.vault")
+
+ assert len(manifest.locations) == 2
+ assert manifest.locations[1].path == "/disk2/vault.vault"
+ assert manifest.locations[1].status == "active"
+ assert manifest.last_modified >= original_modified
+
+ def test_update_location_status(self) -> None:
+ """Test updating location status."""
+ manifest = Manifest.create_new(
+ vault_name="Test",
+ image_size_mb=512,
+ location_path="/disk1/vault.vault",
+ )
+
+ manifest.update_location_status("/disk1/vault.vault", "unreachable")
+
+ assert manifest.locations[0].status == "unreachable"
+
+ def test_add_file(self) -> None:
+ """Test adding a file entry."""
+ manifest = Manifest.create_new(
+ vault_name="Test",
+ image_size_mb=512,
+ location_path="/disk1/vault.vault",
+ )
+ now = datetime.now()
+ file_entry = FileEntry(
+ path="test.txt",
+ hash="sha256:abc",
+ size=100,
+ created=now,
+ modified=now,
+ )
+
+ manifest.add_file(file_entry)
+
+ assert len(manifest.files) == 1
+ assert manifest.files[0].path == "test.txt"
+
+ def test_add_file_updates_existing(self) -> None:
+ """Test that adding a file with same path updates it."""
+ manifest = Manifest.create_new(
+ vault_name="Test",
+ image_size_mb=512,
+ location_path="/disk1/vault.vault",
+ )
+ now = datetime.now()
+
+ # Add first version
+ entry1 = FileEntry(
+ path="test.txt", hash="sha256:old", size=100, created=now, modified=now
+ )
+ manifest.add_file(entry1)
+
+ # Add updated version
+ entry2 = FileEntry(
+ path="test.txt", hash="sha256:new", size=200, created=now, modified=now
+ )
+ manifest.add_file(entry2)
+
+ assert len(manifest.files) == 1
+ assert manifest.files[0].hash == "sha256:new"
+ assert manifest.files[0].size == 200
+
+ def test_remove_file(self) -> None:
+ """Test removing a file entry."""
+ manifest = Manifest.create_new(
+ vault_name="Test",
+ image_size_mb=512,
+ location_path="/disk1/vault.vault",
+ )
+ now = datetime.now()
+ entry = FileEntry(
+ path="test.txt", hash="sha256:abc", size=100, created=now, modified=now
+ )
+ manifest.add_file(entry)
+
+ manifest.remove_file("test.txt")
+
+ assert len(manifest.files) == 0
+
+ def test_get_file(self) -> None:
+ """Test getting a file entry by path."""
+ manifest = Manifest.create_new(
+ vault_name="Test",
+ image_size_mb=512,
+ location_path="/disk1/vault.vault",
+ )
+ now = datetime.now()
+ entry = FileEntry(
+ path="test.txt", hash="sha256:abc", size=100, created=now, modified=now
+ )
+ manifest.add_file(entry)
+
+ found = manifest.get_file("test.txt")
+ not_found = manifest.get_file("nonexistent.txt")
+
+ assert found is not None
+ assert found.path == "test.txt"
+ assert not_found is None
diff --git a/tests/test_sync_manager.py b/tests/test_sync_manager.py
new file mode 100644
index 0000000..8be5879
--- /dev/null
+++ b/tests/test_sync_manager.py
@@ -0,0 +1,391 @@
+"""Tests for sync_manager module."""
+
+import time
+from pathlib import Path
+from threading import Event
+
+import pytest
+
+from src.core.file_watcher import EventType
+from src.core.manifest import Manifest
+from src.core.sync_manager import (
+ ReplicaMount,
+ SyncEvent,
+ SyncManager,
+ SyncStatus,
+)
+
+
+class TestReplicaMount:
+ """Tests for ReplicaMount dataclass."""
+
+ def test_get_file_path(self, tmp_path: Path) -> None:
+ replica = ReplicaMount(
+ mount_point=tmp_path / "mount",
+ image_path=tmp_path / "vault.vault",
+ is_primary=False,
+ )
+ assert replica.get_file_path("docs/file.txt") == tmp_path / "mount" / "docs" / "file.txt"
+
+
+class TestSyncManager:
+ """Tests for SyncManager class."""
+
+ def test_initial_state(self) -> None:
+ manager = SyncManager()
+ assert manager.status == SyncStatus.IDLE
+ assert manager.replica_count == 0
+ assert manager.primary_mount is None
+
+ def test_add_replica(self, tmp_path: Path) -> None:
+ manager = SyncManager()
+ mount = tmp_path / "mount"
+ mount.mkdir()
+ image = tmp_path / "vault.vault"
+
+ manager.add_replica(mount, image, is_primary=False)
+
+ assert manager.replica_count == 1
+ assert manager.primary_mount is None
+
+ def test_add_primary_replica(self, tmp_path: Path) -> None:
+ manager = SyncManager()
+ mount = tmp_path / "mount"
+ mount.mkdir()
+ image = tmp_path / "vault.vault"
+
+ manager.add_replica(mount, image, is_primary=True)
+
+ assert manager.replica_count == 1
+ assert manager.primary_mount == mount
+
+ def test_remove_replica(self, tmp_path: Path) -> None:
+ manager = SyncManager()
+ mount = tmp_path / "mount"
+ mount.mkdir()
+ image = tmp_path / "vault.vault"
+
+ manager.add_replica(mount, image)
+ assert manager.replica_count == 1
+
+ result = manager.remove_replica(mount)
+ assert result is True
+ assert manager.replica_count == 0
+
+ def test_remove_nonexistent_replica(self, tmp_path: Path) -> None:
+ manager = SyncManager()
+ result = manager.remove_replica(tmp_path / "nonexistent")
+ assert result is False
+
+ def test_start_watching_without_primary_raises(self) -> None:
+ manager = SyncManager()
+ with pytest.raises(ValueError, match="No primary replica"):
+ manager.start_watching()
+
+ def test_start_and_stop_watching(self, tmp_path: Path) -> None:
+ manager = SyncManager()
+ mount = tmp_path / "mount"
+ mount.mkdir()
+ image = tmp_path / "vault.vault"
+
+ manager.add_replica(mount, image, is_primary=True)
+ manager.start_watching()
+ manager.stop_watching()
+
+ def test_pause_and_resume_sync(self, tmp_path: Path) -> None:
+ events: list[SyncEvent] = []
+ manager = SyncManager(on_sync_event=events.append)
+
+ primary = tmp_path / "primary"
+ secondary = tmp_path / "secondary"
+ primary.mkdir()
+ secondary.mkdir()
+
+ manager.add_replica(primary, tmp_path / "primary.vault", is_primary=True)
+ manager.add_replica(secondary, tmp_path / "secondary.vault")
+ manager.start_watching()
+
+ # Pause sync
+ manager.pause_sync()
+
+ # Create file while paused
+ (primary / "paused.txt").write_text("created while paused")
+ time.sleep(0.3)
+
+ # No events should be recorded
+ assert len(events) == 0
+ assert not (secondary / "paused.txt").exists()
+
+ manager.stop_watching()
+
+
+class TestSyncManagerPropagation:
+ """Tests for file propagation in SyncManager."""
+
+ def test_propagate_file_creation(self, tmp_path: Path) -> None:
+ events: list[SyncEvent] = []
+ event_received = Event()
+
+ def on_event(event: SyncEvent) -> None:
+ events.append(event)
+ event_received.set()
+
+ manager = SyncManager(on_sync_event=on_event)
+
+ primary = tmp_path / "primary"
+ secondary = tmp_path / "secondary"
+ primary.mkdir()
+ secondary.mkdir()
+
+ manager.add_replica(primary, tmp_path / "primary.vault", is_primary=True)
+ manager.add_replica(secondary, tmp_path / "secondary.vault")
+ manager.start_watching()
+
+ # Create file in primary
+ (primary / "test.txt").write_text("hello")
+
+ # Wait for sync
+ event_received.wait(timeout=2.0)
+ manager.stop_watching()
+
+ # Check file was synced
+ assert (secondary / "test.txt").exists()
+ assert (secondary / "test.txt").read_text() == "hello"
+
+ # Check event
+ created_events = [e for e in events if e.event_type == EventType.CREATED]
+ assert len(created_events) >= 1
+
+ def test_propagate_file_deletion(self, tmp_path: Path) -> None:
+ events: list[SyncEvent] = []
+ event_received = Event()
+
+ def on_event(event: SyncEvent) -> None:
+ events.append(event)
+ if event.event_type == EventType.DELETED:
+ event_received.set()
+
+ manager = SyncManager(on_sync_event=on_event)
+
+ primary = tmp_path / "primary"
+ secondary = tmp_path / "secondary"
+ primary.mkdir()
+ secondary.mkdir()
+
+ # Create file in both
+ (primary / "delete.txt").write_text("to delete")
+ (secondary / "delete.txt").write_text("to delete")
+
+ manager.add_replica(primary, tmp_path / "primary.vault", is_primary=True)
+ manager.add_replica(secondary, tmp_path / "secondary.vault")
+ manager.start_watching()
+
+ # Delete file in primary
+ (primary / "delete.txt").unlink()
+
+ # Wait for sync
+ event_received.wait(timeout=2.0)
+ manager.stop_watching()
+
+ # Check file was deleted in secondary
+ assert not (secondary / "delete.txt").exists()
+
+ def test_propagate_file_move(self, tmp_path: Path) -> None:
+ events: list[SyncEvent] = []
+ event_received = Event()
+
+ def on_event(event: SyncEvent) -> None:
+ events.append(event)
+ if event.event_type == EventType.MOVED:
+ event_received.set()
+
+ manager = SyncManager(on_sync_event=on_event)
+
+ primary = tmp_path / "primary"
+ secondary = tmp_path / "secondary"
+ primary.mkdir()
+ secondary.mkdir()
+
+ # Create file in both
+ (primary / "old.txt").write_text("content")
+ (secondary / "old.txt").write_text("content")
+
+ manager.add_replica(primary, tmp_path / "primary.vault", is_primary=True)
+ manager.add_replica(secondary, tmp_path / "secondary.vault")
+ manager.start_watching()
+
+ # Move file in primary
+ (primary / "old.txt").rename(primary / "new.txt")
+
+ # Wait for sync
+ event_received.wait(timeout=2.0)
+ manager.stop_watching()
+
+ # Check file was moved in secondary
+ assert not (secondary / "old.txt").exists()
+ assert (secondary / "new.txt").exists()
+
+ def test_propagate_to_multiple_replicas(self, tmp_path: Path) -> None:
+ events: list[SyncEvent] = []
+ event_received = Event()
+
+ def on_event(event: SyncEvent) -> None:
+ events.append(event)
+ event_received.set()
+
+ manager = SyncManager(on_sync_event=on_event)
+
+ primary = tmp_path / "primary"
+ secondary1 = tmp_path / "secondary1"
+ secondary2 = tmp_path / "secondary2"
+ primary.mkdir()
+ secondary1.mkdir()
+ secondary2.mkdir()
+
+ manager.add_replica(primary, tmp_path / "primary.vault", is_primary=True)
+ manager.add_replica(secondary1, tmp_path / "secondary1.vault")
+ manager.add_replica(secondary2, tmp_path / "secondary2.vault")
+ manager.start_watching()
+
+ # Create file in primary
+ (primary / "multi.txt").write_text("multi content")
+
+ # Wait for sync
+ event_received.wait(timeout=2.0)
+ time.sleep(0.2) # Extra time for all replicas
+ manager.stop_watching()
+
+ # Check file was synced to all secondaries
+ assert (secondary1 / "multi.txt").exists()
+ assert (secondary2 / "multi.txt").exists()
+
+
+class TestSyncManagerManifestSync:
+ """Tests for manifest-based synchronization."""
+
+ def test_sync_from_manifest_new_file(self, tmp_path: Path) -> None:
+ manager = SyncManager()
+
+ primary = tmp_path / "primary"
+ secondary = tmp_path / "secondary"
+ primary.mkdir()
+ secondary.mkdir()
+ (primary / ".vault").mkdir()
+ (secondary / ".vault").mkdir()
+
+ # Create file in primary
+ (primary / "newfile.txt").write_text("new content")
+
+ manager.add_replica(primary, tmp_path / "primary.vault", is_primary=True)
+ manager.add_replica(secondary, tmp_path / "secondary.vault")
+
+ # Create manifests
+ source_manifest = Manifest.create_new("Test", 100, str(tmp_path / "primary.vault"))
+ source_manifest.add_file_from_path(primary, primary / "newfile.txt")
+
+ target_manifest = Manifest.create_new("Test", 100, str(tmp_path / "secondary.vault"))
+
+ # Sync
+ synced = manager.sync_from_manifest(source_manifest, secondary, target_manifest)
+
+ assert synced == 1
+ assert (secondary / "newfile.txt").exists()
+ assert (secondary / "newfile.txt").read_text() == "new content"
+
+ def test_sync_from_manifest_newer_source(self, tmp_path: Path) -> None:
+ manager = SyncManager()
+
+ primary = tmp_path / "primary"
+ secondary = tmp_path / "secondary"
+ primary.mkdir()
+ secondary.mkdir()
+ (primary / ".vault").mkdir()
+ (secondary / ".vault").mkdir()
+
+ # Create file in both with different content
+ (secondary / "update.txt").write_text("old content")
+ time.sleep(0.1)
+ (primary / "update.txt").write_text("new content")
+
+ manager.add_replica(primary, tmp_path / "primary.vault", is_primary=True)
+ manager.add_replica(secondary, tmp_path / "secondary.vault")
+
+ # Create manifests
+ source_manifest = Manifest.create_new("Test", 100, str(tmp_path / "primary.vault"))
+ source_manifest.add_file_from_path(primary, primary / "update.txt")
+
+ target_manifest = Manifest.create_new("Test", 100, str(tmp_path / "secondary.vault"))
+ target_manifest.add_file_from_path(secondary, secondary / "update.txt")
+
+ # Sync
+ synced = manager.sync_from_manifest(source_manifest, secondary, target_manifest)
+
+ assert synced == 1
+ assert (secondary / "update.txt").read_text() == "new content"
+
+ def test_sync_from_manifest_deleted_file(self, tmp_path: Path) -> None:
+ manager = SyncManager()
+
+ primary = tmp_path / "primary"
+ secondary = tmp_path / "secondary"
+ primary.mkdir()
+ secondary.mkdir()
+ (primary / ".vault").mkdir()
+ (secondary / ".vault").mkdir()
+
+ # Create file only in secondary (simulating deletion in primary)
+ (secondary / "deleted.txt").write_text("will be deleted")
+
+ manager.add_replica(primary, tmp_path / "primary.vault", is_primary=True)
+ manager.add_replica(secondary, tmp_path / "secondary.vault")
+
+ # Create manifests - source has no files, target has one
+ source_manifest = Manifest.create_new("Test", 100, str(tmp_path / "primary.vault"))
+
+ target_manifest = Manifest.create_new("Test", 100, str(tmp_path / "secondary.vault"))
+ target_manifest.add_file_from_path(secondary, secondary / "deleted.txt")
+
+ # Sync
+ synced = manager.sync_from_manifest(source_manifest, secondary, target_manifest)
+
+ assert synced == 1
+ assert not (secondary / "deleted.txt").exists()
+
+ def test_full_sync(self, tmp_path: Path) -> None:
+ manager = SyncManager()
+
+ primary = tmp_path / "primary"
+ secondary1 = tmp_path / "secondary1"
+ secondary2 = tmp_path / "secondary2"
+ primary.mkdir()
+ secondary1.mkdir()
+ secondary2.mkdir()
+ (primary / ".vault").mkdir()
+ (secondary1 / ".vault").mkdir()
+ (secondary2 / ".vault").mkdir()
+
+ # Create files in primary
+ (primary / "file1.txt").write_text("content1")
+ (primary / "file2.txt").write_text("content2")
+
+ manager.add_replica(primary, tmp_path / "primary.vault", is_primary=True)
+ manager.add_replica(secondary1, tmp_path / "secondary1.vault")
+ manager.add_replica(secondary2, tmp_path / "secondary2.vault")
+
+ # Create and save primary manifest
+ primary_manifest = Manifest.create_new("Test", 100, str(tmp_path / "primary.vault"))
+ primary_manifest.add_file_from_path(primary, primary / "file1.txt")
+ primary_manifest.add_file_from_path(primary, primary / "file2.txt")
+ primary_manifest.save(primary)
+
+ # Create empty manifests for secondaries
+ Manifest.create_new("Test", 100, str(tmp_path / "secondary1.vault")).save(secondary1)
+ Manifest.create_new("Test", 100, str(tmp_path / "secondary2.vault")).save(secondary2)
+
+ # Full sync
+ results = manager.full_sync()
+
+ assert results[secondary1] == 2
+ assert results[secondary2] == 2
+ assert (secondary1 / "file1.txt").read_text() == "content1"
+ assert (secondary2 / "file2.txt").read_text() == "content2"
diff --git a/tests/test_vault.py b/tests/test_vault.py
new file mode 100644
index 0000000..d2480fd
--- /dev/null
+++ b/tests/test_vault.py
@@ -0,0 +1,233 @@
+"""Tests for vault module."""
+
+import time
+from pathlib import Path
+
+import pytest
+
+from src.core.image_manager import create_sparse_image
+from src.core.vault import Vault, VaultError, VaultState
+
+
+@pytest.fixture
+def vault_image(tmp_path: Path) -> Path:
+ """Create a test vault image."""
+ image_path = tmp_path / "test.vault"
+ create_sparse_image(image_path, 10) # 10 MB
+ return image_path
+
+
+class TestVault:
+ """Tests for Vault class."""
+
+ def test_initial_state(self) -> None:
+ vault = Vault()
+ assert vault.state == VaultState.CLOSED
+ assert vault.is_open is False
+ assert vault.mount_point is None
+ assert vault.replica_count == 0
+
+ @pytest.mark.integration
+ def test_open_and_close(self, vault_image: Path) -> None:
+ vault = Vault()
+
+ # Open
+ mount = vault.open(vault_image)
+ assert vault.is_open
+ assert vault.state == VaultState.OPEN
+ assert vault.mount_point == mount
+ assert mount.exists()
+
+ # Close
+ vault.close()
+ assert vault.state == VaultState.CLOSED
+ assert vault.is_open is False
+ assert vault.mount_point is None
+
+ @pytest.mark.integration
+ def test_context_manager(self, vault_image: Path) -> None:
+ with Vault() as vault:
+ vault.open(vault_image)
+ assert vault.is_open
+
+ assert vault.state == VaultState.CLOSED
+
+ @pytest.mark.integration
+ def test_state_change_callback(self, vault_image: Path) -> None:
+ states: list[VaultState] = []
+
+ def on_state_change(state: VaultState) -> None:
+ states.append(state)
+
+ vault = Vault(on_state_change=on_state_change)
+ vault.open(vault_image)
+ vault.close()
+
+ assert VaultState.OPENING in states
+ assert VaultState.OPEN in states
+ assert VaultState.CLOSED in states
+
+ @pytest.mark.integration
+ def test_open_creates_manifest(self, vault_image: Path) -> None:
+ vault = Vault()
+ mount = vault.open(vault_image)
+
+ assert vault.manifest is not None
+ assert vault.manifest.vault_name == "test" # from filename
+ assert (mount / ".vault" / "manifest.json").exists()
+
+ vault.close()
+
+ @pytest.mark.integration
+ def test_open_already_open_raises(self, vault_image: Path) -> None:
+ vault = Vault()
+ vault.open(vault_image)
+
+ with pytest.raises(VaultError, match="already open"):
+ vault.open(vault_image)
+
+ vault.close()
+
+ @pytest.mark.integration
+ def test_get_replicas(self, vault_image: Path) -> None:
+ vault = Vault()
+ vault.open(vault_image)
+
+ replicas = vault.get_replicas()
+ assert len(replicas) == 1
+ assert replicas[0].is_primary is True
+ assert replicas[0].is_mounted is True
+ assert replicas[0].image_path == vault_image
+
+ vault.close()
+
+
+class TestVaultLocking:
+ """Tests for vault locking."""
+
+ @pytest.mark.integration
+ def test_vault_is_locked_when_open(self, vault_image: Path) -> None:
+ vault = Vault()
+ vault.open(vault_image)
+
+ # Lock file should exist
+ lock_path = vault_image.parent / f".{vault_image.stem}.lock"
+ assert lock_path.exists()
+
+ vault.close()
+
+ # Lock should be released
+ assert not lock_path.exists()
+
+ @pytest.mark.integration
+ def test_second_vault_cannot_open_locked(self, vault_image: Path) -> None:
+ vault1 = Vault()
+ vault1.open(vault_image)
+
+ vault2 = Vault()
+ with pytest.raises(VaultError, match="locked"):
+ vault2.open(vault_image)
+
+ vault1.close()
+
+
+class TestVaultReplicas:
+ """Tests for vault replica management."""
+
+ @pytest.mark.integration
+ def test_add_replica(self, vault_image: Path, tmp_path: Path) -> None:
+ vault = Vault()
+ vault.open(vault_image)
+
+ # Create a file in primary
+ (vault.mount_point / "test.txt").write_text("hello") # type: ignore
+
+ # Add replica
+ replica_path = tmp_path / "replica.vault"
+ replica_mount = vault.add_replica(replica_path)
+
+ assert vault.replica_count == 2
+ assert replica_path.exists()
+ assert (replica_mount / "test.txt").exists()
+ assert (replica_mount / "test.txt").read_text() == "hello"
+
+ vault.close()
+
+ @pytest.mark.integration
+ def test_remove_replica(self, vault_image: Path, tmp_path: Path) -> None:
+ vault = Vault()
+ vault.open(vault_image)
+
+ # Add replica
+ replica_path = tmp_path / "replica.vault"
+ vault.add_replica(replica_path)
+ assert vault.replica_count == 2
+
+ # Remove replica
+ vault.remove_replica(replica_path)
+ assert vault.replica_count == 1
+
+ vault.close()
+
+ @pytest.mark.integration
+ def test_cannot_remove_primary(self, vault_image: Path) -> None:
+ vault = Vault()
+ vault.open(vault_image)
+
+ with pytest.raises(VaultError, match="primary"):
+ vault.remove_replica(vault_image)
+
+ vault.close()
+
+
+class TestVaultSync:
+ """Tests for vault synchronization."""
+
+ @pytest.mark.integration
+ def test_file_propagation(self, vault_image: Path, tmp_path: Path) -> None:
+ vault = Vault()
+ vault.open(vault_image)
+
+ # Add replica
+ replica_path = tmp_path / "replica.vault"
+ replica_mount = vault.add_replica(replica_path)
+
+ # Create file in primary - should propagate to replica
+ (vault.mount_point / "sync_test.txt").write_text("synced content") # type: ignore
+ time.sleep(0.5) # Wait for sync
+
+ assert (replica_mount / "sync_test.txt").exists()
+ assert (replica_mount / "sync_test.txt").read_text() == "synced content"
+
+ vault.close()
+
+ @pytest.mark.integration
+ def test_manual_sync(self, vault_image: Path, tmp_path: Path) -> None:
+ vault = Vault()
+ vault.open(vault_image)
+
+ # Create file before adding replica
+ (vault.mount_point / "existing.txt").write_text("existing") # type: ignore
+
+ # Add replica (should sync during add)
+ replica_path = tmp_path / "replica.vault"
+ replica_mount = vault.add_replica(replica_path)
+
+ assert (replica_mount / "existing.txt").exists()
+
+ # Create another file
+ (vault.mount_point / "new.txt").write_text("new") # type: ignore
+
+ # Pause sync and modify
+ vault._sync_manager.pause_sync() # type: ignore
+ (vault.mount_point / "paused.txt").write_text("paused") # type: ignore
+ time.sleep(0.3)
+
+ # File shouldn't be in replica yet
+ # (might be there due to timing, so just trigger manual sync)
+
+ # Resume and sync manually
+ vault._sync_manager.resume_sync() # type: ignore
+ vault.sync()
+
+ vault.close()