Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 45 additions & 22 deletions bbackup/management/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,44 @@
Dependency checking and installation.
"""

import re
import subprocess
import sys
import re
import tomllib
from importlib.metadata import PackageNotFoundError, version as package_version
from pathlib import Path
from typing import Dict, List, Tuple
from typing import Dict, List, Optional, Tuple

from rich.console import Console
from rich.table import Table
from rich.prompt import Confirm

console = Console()

REQUIRED_PYTHON_PACKAGES = {
"rich": "rich",
"pyyaml": "yaml",
"docker": "docker",
"click": "click",
"paramiko": "paramiko",
"cryptography": "cryptography",
"requests": "requests",
}
MINIMUM_PYTHON_PACKAGE_VERSIONS = {
"cryptography": (50, 0, 0),
}


def _release_tuple(raw_version: str) -> Optional[Tuple[int, int, int]]:
"""Return a stable three-part release tuple, rejecting prereleases."""
match = re.fullmatch(
r"(\d+)\.(\d+)\.(\d+)(?:\.post\d+)?(?:\+[A-Za-z0-9.-]+)?",
raw_version,
)
if match is None:
return None
return tuple(int(part) for part in match.groups())


def check_system_dependencies() -> Dict[str, Tuple[bool, str]]:
"""
Expand Down Expand Up @@ -63,32 +88,30 @@ def check_system_dependencies() -> Dict[str, Tuple[bool, str]]:


def check_python_dependencies() -> Tuple[bool, List[str], List[str]]:
"""
Check Python package dependencies.

Returns:
Tuple of (all_installed, installed_packages, missing_packages)
"""
required = {
"rich": "rich",
"pyyaml": "yaml",
"docker": "docker",
"click": "click",
"paramiko": "paramiko",
"cryptography": "cryptography",
"requests": "requests",
}

"""Check required Python package imports and security version floors."""
installed = []
missing = []
for package_name, import_name in required.items():

for package_name, import_name in REQUIRED_PYTHON_PACKAGES.items():
try:
__import__(import_name)
installed.append(package_name)
except ImportError:
missing.append(package_name)

continue

minimum = MINIMUM_PYTHON_PACKAGE_VERSIONS.get(package_name)
if minimum is not None:
try:
current = _release_tuple(package_version(package_name))
except PackageNotFoundError:
current = None
if current is None or current < minimum:
floor = ".".join(str(part) for part in minimum)
missing.append(f"{package_name}>={floor}")
continue

installed.append(package_name)

return len(missing) == 0, installed, missing


Expand Down
25 changes: 3 additions & 22 deletions bbackup/management/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from ..config import Config
from ..snapshot import check_all_snapshot_profiles
from .dependencies import check_python_dependencies as _check_python_dependencies

console = Console()

Expand Down Expand Up @@ -71,28 +72,8 @@ def check_system_tool(tool: str) -> Tuple[bool, str]:


def check_python_packages() -> Tuple[bool, List[str], List[str]]:
"""Check if required Python packages are installed."""
required = {
"rich": "rich",
"pyyaml": "yaml",
"docker": "docker",
"click": "click",
"paramiko": "paramiko",
"cryptography": "cryptography",
"requests": "requests",
}

installed = []
missing = []

for package_name, import_name in required.items():
try:
__import__(import_name)
installed.append(package_name)
except ImportError:
missing.append(package_name)

return len(missing) == 0, installed, missing
"""Check required Python package imports and security version floors."""
return _check_python_dependencies()


def check_config_file() -> Tuple[bool, str]:
Expand Down
23 changes: 4 additions & 19 deletions bbackup/management/setup_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from rich.table import Table

from .first_run import mark_first_run_complete, get_config_file
from .dependencies import check_python_dependencies as _check_python_dependencies


console = Console()
Expand Down Expand Up @@ -54,25 +55,9 @@ def check_system_tool(tool: str) -> Tuple[bool, str]:


def check_python_packages() -> Tuple[bool, List[str]]:
"""Check if required Python packages are installed."""
required = {
"rich": "rich",
"pyyaml": "yaml",
"docker": "docker",
"click": "click",
"paramiko": "paramiko",
"cryptography": "cryptography",
"requests": "requests",
}
missing = []

for package, import_name in required.items():
try:
__import__(import_name)
except ImportError:
missing.append(package)

return len(missing) == 0, missing
"""Check required Python package imports and security version floors."""
all_ok, _, missing = _check_python_dependencies()
return all_ok, missing


def run_setup_wizard() -> bool:
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
| Docker integration | docker-py SDK | 7.0.0+ |
| Config format | PyYAML | 6.0.1+ |
| SFTP | paramiko | 3.4.0+ |
| Encryption | cryptography | 48.0.1+ |
| Encryption | cryptography | 50.0.0+ |
| HTTP (key fetching) | requests | 2.31.0+ |
| Volume backup | rsync | system |
| Cloud storage | rclone | optional |
Expand Down
20 changes: 20 additions & 0 deletions tests/test_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,26 @@ def test_check_python_dependencies_all_present(self):
assert isinstance(installed, list)
assert isinstance(missing, list)

def test_check_python_dependencies_rejects_old_cryptography(self):
from bbackup.management.dependencies import check_python_dependencies

with patch(
"bbackup.management.dependencies.package_version",
return_value="49.0.0",
):
ok, installed, missing = check_python_dependencies()

assert ok is False
assert "cryptography>=50.0.0" in missing
assert "cryptography" not in installed

def test_release_tuple_accepts_stable_post_and_local_versions(self):
from bbackup.management.dependencies import _release_tuple

assert _release_tuple("50.0.0.post1+vendor") == (50, 0, 0)
assert _release_tuple("50.0.0+vendor") == (50, 0, 0)
assert _release_tuple("50.0.0rc1") is None

def test_check_project_dependencies_reads_packages(self):
from bbackup.management.dependencies import check_project_dependencies
result = check_project_dependencies()
Expand Down