ytptube/app/tests/test_package_installer.py
2026-05-04 22:23:19 +03:00

272 lines
11 KiB
Python

import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from app.library.PackageInstaller import PackageInstaller, Packages, parse_version
class TestParseVersion:
def test_parse_version_basic(self) -> None:
assert parse_version("1.2.3") == (1, 2, 3)
assert parse_version("01.002.0003") == (1, 2, 3)
def test_parse_version_with_chars(self) -> None:
# Non-digits are stripped per part
assert parse_version("1a.2b.3c") == (1, 2, 3)
assert parse_version("2025.07.21") == (2025, 7, 21)
class TestPackages:
def test_packages_from_env_and_file(self, tmp_path: Path) -> None:
req = tmp_path / "req.txt"
req.write_text("\nfoo\nbar==1.0.0\nfoo\n\n")
pkgs = Packages(env="baz qux", file=str(req), upgrade=True)
# Order not guaranteed (set), but content should be unique
assert set(pkgs.packages) == {"foo", "bar==1.0.0", "baz", "qux"}
assert pkgs.has_packages() is True
assert pkgs.allow_upgrade() is True
def test_packages_empty(self) -> None:
pkgs = Packages(env=None, file=None, upgrade=False)
assert pkgs.has_packages() is False
assert pkgs.allow_upgrade() is False
class TestPackageInstallerInit:
def test_init_adds_sys_path(self, tmp_path: Path) -> None:
p = tmp_path / "site"
p.mkdir()
# Snapshot sys.path length to verify insertion
original_len = len(sys.path)
installer = PackageInstaller(pkg_path=p)
assert installer.user_site == p
assert sys.path[0] == str(p)
assert len(sys.path) == original_len + 1
def test_init_with_env_var(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("YTP_CONFIG_PATH", str(tmp_path))
installer = PackageInstaller(pkg_path=None)
assert installer.user_site is not None
assert installer.user_site.exists() is True
assert str(installer.user_site) in sys.path
def test_init_no_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("YTP_CONFIG_PATH", raising=False)
installer = PackageInstaller(pkg_path=None)
# No user_site is set when no path or env provided
assert installer.user_site is None
class TestVersionCompare:
def test_compare_versions_equal(self, tmp_path: Path) -> None:
inst = PackageInstaller(pkg_path=tmp_path)
assert inst.compare_versions("1.2.3", "1.2.3") is True
def test_compare_versions_yt_dlp_like_padding(self, tmp_path: Path) -> None:
inst = PackageInstaller(pkg_path=tmp_path)
assert inst.compare_versions("2025.7.21", "2025.07.21") is True
assert inst.compare_versions("2025.07.1", "2025.7.01") is True
def test_compare_versions_not_equal(self, tmp_path: Path) -> None:
inst = PackageInstaller(pkg_path=tmp_path)
assert inst.compare_versions("1.2.3", "1.2.4") is False
class TestInstalledAndLatest:
@patch("app.library.PackageInstaller.importlib.metadata.version")
def test_get_installed_version(self, mock_version, tmp_path: Path) -> None:
inst = PackageInstaller(pkg_path=tmp_path)
mock_version.return_value = "1.0.0"
assert inst._get_installed_version("foo") == "1.0.0"
@patch("app.library.PackageInstaller.importlib.metadata.version")
def test_get_installed_version_not_found(self, mock_version, tmp_path: Path) -> None:
inst = PackageInstaller(pkg_path=tmp_path)
from importlib.metadata import PackageNotFoundError
mock_version.side_effect = PackageNotFoundError
assert inst._get_installed_version("bar") is None
@patch("app.library.PackageInstaller.sync_client")
def test_get_latest_version_success(self, mock_client, tmp_path: Path) -> None:
inst = PackageInstaller(pkg_path=tmp_path)
client = MagicMock()
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {"info": {"version": "9.9.9"}}
client.get.return_value = resp
mock_client.return_value.__enter__.return_value = client
assert inst._get_latest_version("foo") == "9.9.9"
@patch("app.library.PackageInstaller.sync_client")
def test_get_latest_version_non_200(self, mock_client, tmp_path: Path) -> None:
inst = PackageInstaller(pkg_path=tmp_path)
client = MagicMock()
resp = MagicMock()
resp.status_code = 404
client.get.return_value = resp
mock_client.return_value.__enter__.return_value = client
assert inst._get_latest_version("foo") is None
@patch("app.library.PackageInstaller.sync_client")
def test_get_latest_version_exception(self, mock_client, tmp_path: Path) -> None:
inst = PackageInstaller(pkg_path=tmp_path)
mock_client.side_effect = RuntimeError("boom")
assert inst._get_latest_version("foo") is None
class TestInstallCmd:
@patch("app.library.PackageInstaller.subprocess.run")
def test_install_default_latest(self, mock_run, tmp_path: Path) -> None:
inst = PackageInstaller(pkg_path=tmp_path)
# Simulate successful run
mock_run.return_value = SimpleNamespace(returncode=0, stdout=b"out", stderr=b"err")
ok = inst._install_pkg("pkg")
assert ok is True
cmd = mock_run.call_args.kwargs["args"] if "args" in mock_run.call_args.kwargs else mock_run.call_args.args[0]
assert cmd[:5] == [sys.executable, "-m", "pip", "install", "--no-warn-script-location"]
assert "--disable-pip-version-check" in cmd
assert "pkg" in cmd
assert "--target" in cmd
assert str(inst.user_site) in cmd
@patch("app.library.PackageInstaller.subprocess.run")
def test_install_pinned_version(self, mock_run, tmp_path: Path) -> None:
inst = PackageInstaller(pkg_path=tmp_path)
mock_run.return_value = SimpleNamespace(returncode=0, stdout=b"o", stderr=b"e")
ok = inst._install_pkg("pkg", version="1.2.3")
assert ok is True
cmd = mock_run.call_args.args[0]
assert "pkg==1.2.3" in cmd
@patch("app.library.PackageInstaller.subprocess.run")
def test_install_git_url_version(self, mock_run, tmp_path: Path) -> None:
inst = PackageInstaller(pkg_path=tmp_path)
mock_run.return_value = SimpleNamespace(returncode=0, stdout=b"o", stderr=b"e")
ok = inst._install_pkg("pkg", version="git+https://example/repo.git@abc")
assert ok is True
cmd = mock_run.call_args.args[0]
assert "git+https://example/repo.git@abc" in cmd
@patch("app.library.PackageInstaller.subprocess.run")
def test_install_yt_dlp_nightly(self, mock_run, tmp_path: Path) -> None:
inst = PackageInstaller(pkg_path=tmp_path)
mock_run.return_value = SimpleNamespace(returncode=0, stdout=b"o", stderr=b"e")
ok = inst._install_pkg("yt_dlp", version="nightly")
assert ok is True
cmd = mock_run.call_args.args[0]
# should include pre-release flag and yt-dlp extra
assert "--pre" in cmd
assert "yt-dlp[default]" in cmd
@patch("app.library.PackageInstaller.subprocess.run")
def test_install_yt_dlp_master(self, mock_run, tmp_path: Path) -> None:
inst = PackageInstaller(pkg_path=tmp_path)
mock_run.return_value = SimpleNamespace(returncode=0, stdout=b"o", stderr=b"e")
ok = inst._install_pkg("yt_dlp", version="master")
assert ok is True
cmd = mock_run.call_args.args[0]
assert "git+https://github.com/yt-dlp/yt-dlp.git@master" in cmd
@patch("app.library.PackageInstaller.subprocess.run")
def test_install_called_process_error(self, mock_run, tmp_path: Path) -> None:
inst = PackageInstaller(pkg_path=tmp_path)
err = SimpleNamespace(returncode=1, stdout=b"o", stderr=b"e")
class _CPEError(Exception):
def __init__(self) -> None:
self.returncode = err.returncode
self.stdout = err.stdout
self.stderr = err.stderr
super().__init__("fail")
mock_run.side_effect = _CPEError()
with pytest.raises(_CPEError, match="fail"):
inst._install_pkg("pkg")
class TestActionAndCheck:
@patch.object(PackageInstaller, "_install_pkg")
@patch.object(PackageInstaller, "_get_installed_version")
def test_action_skip_pinned(self, mock_get_installed, mock_install, tmp_path: Path) -> None:
inst = PackageInstaller(pkg_path=tmp_path)
mock_get_installed.return_value = "1.2.3"
# compare_versions normal equality should hold
inst.action("pkg==1.2.3")
mock_install.assert_not_called()
@patch.object(PackageInstaller, "_install_pkg")
@patch.object(PackageInstaller, "_get_installed_version")
@patch.object(PackageInstaller, "_get_latest_version")
def test_action_skip_latest(self, mock_get_latest, mock_get_installed, mock_install, tmp_path: Path) -> None:
inst = PackageInstaller(pkg_path=tmp_path)
mock_get_installed.return_value = "2.0.0"
mock_get_latest.return_value = "2.0.0"
inst.action("pkg", upgrade=True)
mock_install.assert_not_called()
@patch.object(PackageInstaller, "_install_pkg")
@patch.object(PackageInstaller, "_get_installed_version")
@patch.object(PackageInstaller, "_get_latest_version")
def test_action_upgrade_newer(self, mock_get_latest, mock_get_installed, mock_install, tmp_path: Path) -> None:
inst = PackageInstaller(pkg_path=tmp_path)
mock_get_installed.return_value = "1.0.0"
mock_get_latest.return_value = "1.1.0"
inst.action("pkg", upgrade=True)
mock_install.assert_called_once_with("pkg", version=None)
@patch.object(PackageInstaller, "_install_pkg")
@patch.object(PackageInstaller, "_get_installed_version")
def test_action_install_missing(self, mock_get_installed, mock_install, tmp_path: Path) -> None:
inst = PackageInstaller(pkg_path=tmp_path)
mock_get_installed.return_value = None
inst.action("pkg")
mock_install.assert_called_once_with("pkg", version=None)
def test_check_no_packages_or_path(self, tmp_path: Path) -> None:
# No packages
inst = PackageInstaller(pkg_path=tmp_path)
pkgs = Packages(env=None, file=None, upgrade=False)
inst.check(pkgs) # Should do nothing
# No user_site => early return
inst2 = PackageInstaller(pkg_path=None)
pkgs2 = Packages(env="a b", file=None, upgrade=False)
inst2.check(pkgs2)
@patch.object(PackageInstaller, "action")
def test_check_runs_all(self, mock_action, tmp_path: Path) -> None:
inst = PackageInstaller(pkg_path=tmp_path)
pkgs = Packages(env="foo bar", file=None, upgrade=True)
# First call raises, second succeeds
def side_effect(pkg, upgrade=False): # noqa: ARG001
if pkg == "foo":
msg = "boom"
raise RuntimeError(msg)
mock_action.side_effect = side_effect
# Should not raise
inst.check(pkgs)
assert mock_action.call_count == 2
mock_action.assert_any_call("foo", upgrade=True)
mock_action.assert_any_call("bar", upgrade=True)