diff --git a/src/platform_ops.py b/src/platform_ops.py index 60c411a..e98f3ae 100644 --- a/src/platform_ops.py +++ b/src/platform_ops.py @@ -8,6 +8,8 @@ import hashlib import platform #from .config import APP_DATA_DIR, DOWNLOAD_TIMEOUT, ensure_dir from .app_log import log +from .net import secure_urlopen +import urllib.request # NOTE: Creates dir and verifies it was made diff --git a/src/tests/test_config.py b/src/tests/test_config.py index e11c7e7..76eb644 100644 --- a/src/tests/test_config.py +++ b/src/tests/test_config.py @@ -1,125 +1,885 @@ +import json +import os +import threading +from pathlib import Path +from unittest.mock import Mock, call import pytest -import json -from pathlib import Path + from src import config +# ============================================================================ +# Module constants +# ============================================================================ + +def test_updater_version(): + assert config.UPDATER_VERSION == "1.3.1" -def test_load_config(monkeypatch, tmp_path): - config_file = tmp_path / "config.json" - config_file.write_text( - json.dumps({"game_dir": "/games/OctoWoW"}) - ) - - monkeypatch.setattr(config, "CONFIG_FILE", str(config_file)) - - result = config.load_config() - - assert result == {"game_dir": "/games/OctoWoW"} +def test_server(): + assert config.SERVER == "https://octowow.st" -def test_load_config_missing_file(monkeypatch, tmp_path): - config_file = tmp_path / "does_not_exist.json" - - monkeypatch.setattr(config, "CONFIG_FILE", str(config_file)) - - result = config.load_config() - - assert result == {} +def test_user_agent_contains_version(): + assert config.UA == f"OctoUpdater/{config.UPDATER_VERSION}" -def test_load_config_invalid_json(monkeypatch, tmp_path): - config_file = tmp_path / "config.json" - config_file.write_text("this is not json") - - monkeypatch.setattr(config, "CONFIG_FILE", str(config_file)) - - result = config.load_config() - - assert result == {} +def test_download_retry(): + assert config.DOWNLOAD_RETRY == 5 -def test_atomic_write(tmp_path): - config_file = tmp_path / "config.json" - - config._atomic_write( - str(config_file), - '{"test": true}' - ) - - assert config_file.exists() - assert config_file.read_text() == '{"test": true}' - assert not Path(str(config_file) + ".tmp").exists() +def test_download_timeout(): + assert config.DOWNLOAD_TIMEOUT == 10 -def test_atomic_write_replaces_existing_file(tmp_path): - config_file = tmp_path / "config.json" - config_file.write_text("old data") - - config._atomic_write( - str(config_file), - "new data" - ) - - assert config_file.read_text() == "new data" +def test_config_file_is_inside_config_directory(): + assert Path(config.CONFIG_FILE).parent == Path(config.APP_CONFIG_DIR) -def test_save_config(monkeypatch, tmp_path): - config_file = tmp_path / "config.json" - - monkeypatch.setattr(config, "CONFIG_FILE", str(config_file)) - - config.save_config({ - "game_dir": "/games/OctoWoW", - "enabled": True, - }) - - result = json.loads(config_file.read_text()) - - assert result == { - "game_dir": "/games/OctoWoW", - "enabled": True, - } +def test_config_file_name(): + assert Path(config.CONFIG_FILE).name == "config.json" -def test_update_config(monkeypatch, tmp_path): - config_file = tmp_path / "config.json" +def test_default_game_directory_name(): + assert Path(config.DEFAULT_GAME_DIR).name == "OctoWoW" - config_file.write_text( + +def test_default_game_directory_is_anchored_to_app_dir(): + assert Path(config.DEFAULT_GAME_DIR).parent == Path(config.APP_DIR) + + +# ============================================================================ +# load_config +# ============================================================================ + +def test_load_config_reads_valid_json(tmp_path, monkeypatch): + path = tmp_path / "config.json" + + path.write_text( json.dumps({ - "game_dir": "/old/path", - "existing_setting": True, + "game_dir": "/games/octowow", + "ignore_speech": True, }) ) - monkeypatch.setattr(config, "CONFIG_FILE", str(config_file)) + monkeypatch.setattr( + config, + "CONFIG_FILE", + str(path), + ) - def change_game_dir(cfg): - cfg["game_dir"] = "/new/path" + result = config.load_config() - result = config.update_config(change_game_dir) - - assert result["game_dir"] == "/new/path" - assert result["existing_setting"] is True - - saved = json.loads(config_file.read_text()) - - assert saved["game_dir"] == "/new/path" - assert saved["existing_setting"] is True + assert result == { + "game_dir": "/games/octowow", + "ignore_speech": True, + } -def test_ensure_dir(tmp_path): - new_directory = tmp_path / "one" / "two" / "three" +def test_load_config_returns_empty_dict_when_file_missing( + tmp_path, + monkeypatch, +): + path = tmp_path / "missing.json" - config.ensure_dir(new_directory) + monkeypatch.setattr( + config, + "CONFIG_FILE", + str(path), + ) - assert new_directory.exists() - assert new_directory.is_dir() + result = config.load_config() + + assert result == {} -def test_relocate_legacy_data(monkeypatch, tmp_path): +def test_load_config_returns_empty_dict_for_invalid_json( + tmp_path, + monkeypatch, +): + path = tmp_path / "config.json" + path.write_text("{invalid json") + + monkeypatch.setattr( + config, + "CONFIG_FILE", + str(path), + ) + + result = config.load_config() + + assert result == {} + + +def test_load_config_returns_empty_dict_for_empty_file( + tmp_path, + monkeypatch, +): + path = tmp_path / "config.json" + path.write_text("") + + monkeypatch.setattr( + config, + "CONFIG_FILE", + str(path), + ) + + assert config.load_config() == {} + + +def test_load_config_accepts_nested_json( + tmp_path, + monkeypatch, +): + path = tmp_path / "config.json" + + expected = { + "game": { + "directory": "/games/octowow", + "options": { + "speech": False, + }, + }, + "mods": [ + "one", + "two", + ], + } + + path.write_text(json.dumps(expected)) + + monkeypatch.setattr( + config, + "CONFIG_FILE", + str(path), + ) + + assert config.load_config() == expected + + +def test_load_config_preserves_json_types( + tmp_path, + monkeypatch, +): + path = tmp_path / "config.json" + + expected = { + "string": "value", + "integer": 123, + "float": 1.5, + "true": True, + "false": False, + "null": None, + "list": [1, 2, 3], + "dict": {"x": "y"}, + } + + path.write_text(json.dumps(expected)) + + monkeypatch.setattr( + config, + "CONFIG_FILE", + str(path), + ) + + assert config.load_config() == expected + + +def test_load_config_returns_empty_dict_on_open_error( + monkeypatch, +): + def broken_open(*args, **kwargs): + raise PermissionError("permission denied") + + monkeypatch.setattr( + "builtins.open", + broken_open, + ) + + assert config.load_config() == {} + + +# ============================================================================ +# _atomic_write +# ============================================================================ + +def test_atomic_write_creates_file(tmp_path): + path = tmp_path / "config.json" + + config._atomic_write( + str(path), + '{"test": true}', + ) + + assert path.read_text() == '{"test": true}' + + +def test_atomic_write_uses_tmp_file_then_replaces( + tmp_path, + monkeypatch, +): + path = tmp_path / "config.json" + + real_replace = os.replace + observed = {} + + def inspect_replace(src, dst): + observed["src"] = src + observed["dst"] = dst + observed["contents"] = Path(src).read_text() + + real_replace(src, dst) + + monkeypatch.setattr( + config.os, + "replace", + inspect_replace, + ) + + config._atomic_write( + str(path), + "new configuration", + ) + + assert observed["src"] == str(path) + ".tmp" + assert observed["dst"] == str(path) + assert observed["contents"] == "new configuration" + + +def test_atomic_write_leaves_no_tmp_file_after_success( + tmp_path, +): + path = tmp_path / "config.json" + + config._atomic_write( + str(path), + "contents", + ) + + assert path.exists() + assert not Path(str(path) + ".tmp").exists() + + +def test_atomic_write_replaces_existing_file( + tmp_path, +): + path = tmp_path / "config.json" + + path.write_text("old") + + config._atomic_write( + str(path), + "new", + ) + + assert path.read_text() == "new" + + +def test_atomic_write_calls_fsync( + tmp_path, + monkeypatch, +): + path = tmp_path / "config.json" + + fsync = Mock() + + monkeypatch.setattr( + config.os, + "fsync", + fsync, + ) + + config._atomic_write( + str(path), + "contents", + ) + + fsync.assert_called_once() + + +def test_atomic_write_fsync_receives_file_descriptor( + tmp_path, + monkeypatch, +): + path = tmp_path / "config.json" + + received = [] + + def fake_fsync(fd): + received.append(fd) + assert isinstance(fd, int) + + monkeypatch.setattr( + config.os, + "fsync", + fake_fsync, + ) + + config._atomic_write( + str(path), + "contents", + ) + + assert len(received) == 1 + + +def test_atomic_write_propagates_replace_failure( + tmp_path, + monkeypatch, +): + path = tmp_path / "config.json" + + def fail_replace(src, dst): + raise OSError("replace failed") + + monkeypatch.setattr( + config.os, + "replace", + fail_replace, + ) + + with pytest.raises( + OSError, + match="replace failed", + ): + config._atomic_write( + str(path), + "contents", + ) + + +def test_atomic_write_does_not_replace_destination_if_write_fails( + tmp_path, + monkeypatch, +): + path = tmp_path / "config.json" + + replace = Mock() + + monkeypatch.setattr( + config.os, + "replace", + replace, + ) + + real_open = open + + class BrokenFile: + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def write(self, text): + raise OSError("write failed") + + def fake_open(filename, mode="r", *args, **kwargs): + if filename == str(path) + ".tmp": + return BrokenFile() + + return real_open( + filename, + mode, + *args, + **kwargs, + ) + + monkeypatch.setattr( + "builtins.open", + fake_open, + ) + + with pytest.raises( + OSError, + match="write failed", + ): + config._atomic_write( + str(path), + "contents", + ) + + replace.assert_not_called() + + +# ============================================================================ +# save_config +# ============================================================================ + +def test_save_config_writes_json( + tmp_path, + monkeypatch, +): + path = tmp_path / "config.json" + + monkeypatch.setattr( + config, + "CONFIG_FILE", + str(path), + ) + + data = { + "game_dir": "/games/octowow", + "enabled": True, + } + + config.save_config(data) + + assert json.loads(path.read_text()) == data + + +def test_save_config_uses_indent_two( + monkeypatch, +): + atomic_write = Mock() + + monkeypatch.setattr( + config, + "_atomic_write", + atomic_write, + ) + + config.save_config({ + "one": 1, + "two": 2, + }) + + atomic_write.assert_called_once() + + path, text = atomic_write.call_args.args + + assert path == config.CONFIG_FILE + + assert text == json.dumps( + { + "one": 1, + "two": 2, + }, + indent=2, + ) + + +def test_save_config_passes_config_file_to_atomic_write( + monkeypatch, +): + atomic_write = Mock() + + monkeypatch.setattr( + config, + "_atomic_write", + atomic_write, + ) + + config.save_config({"x": 1}) + + assert atomic_write.call_args.args[0] == config.CONFIG_FILE + + +def test_save_config_swallows_atomic_write_error( + monkeypatch, +): + atomic_write = Mock( + side_effect=OSError("disk full") + ) + + monkeypatch.setattr( + config, + "_atomic_write", + atomic_write, + ) + + # Should not raise. + config.save_config({"test": True}) + + atomic_write.assert_called_once() + + +def test_save_config_swallows_json_serialization_error( + monkeypatch, +): + atomic_write = Mock() + + monkeypatch.setattr( + config, + "_atomic_write", + atomic_write, + ) + + data = { + "not_serializable": object(), + } + + # json.dumps() raises TypeError, but save_config() + # deliberately catches Exception. + config.save_config(data) + + atomic_write.assert_not_called() + + +def test_save_config_does_not_mutate_input( + tmp_path, + monkeypatch, +): + path = tmp_path / "config.json" + + monkeypatch.setattr( + config, + "CONFIG_FILE", + str(path), + ) + + data = { + "a": 1, + "nested": { + "b": 2, + }, + } + + expected = { + "a": 1, + "nested": { + "b": 2, + }, + } + + config.save_config(data) + + assert data == expected + + +# ============================================================================ +# update_config +# ============================================================================ + +def test_update_config_loads_mutates_and_saves( + monkeypatch, +): + existing = { + "one": 1, + } + + load = Mock(return_value=existing.copy()) + save = Mock() + + monkeypatch.setattr( + config, + "load_config", + load, + ) + + monkeypatch.setattr( + config, + "save_config", + save, + ) + + def mutator(cfg): + cfg["two"] = 2 + + result = config.update_config(mutator) + + load.assert_called_once_with() + + save.assert_called_once_with({ + "one": 1, + "two": 2, + }) + + assert result == { + "one": 1, + "two": 2, + } + + +def test_update_config_returns_mutated_config( + monkeypatch, +): + monkeypatch.setattr( + config, + "load_config", + Mock(return_value={"a": 1}), + ) + + monkeypatch.setattr( + config, + "save_config", + Mock(), + ) + + result = config.update_config( + lambda cfg: cfg.update({"b": 2}) + ) + + assert result == { + "a": 1, + "b": 2, + } + + +def test_update_config_preserves_existing_keys( + monkeypatch, +): + monkeypatch.setattr( + config, + "load_config", + Mock( + return_value={ + "one": 1, + "two": 2, + } + ), + ) + + save = Mock() + + monkeypatch.setattr( + config, + "save_config", + save, + ) + + result = config.update_config( + lambda cfg: cfg.update({"three": 3}) + ) + + assert result == { + "one": 1, + "two": 2, + "three": 3, + } + + +def test_update_config_can_replace_existing_value( + monkeypatch, +): + monkeypatch.setattr( + config, + "load_config", + Mock( + return_value={ + "enabled": False, + } + ), + ) + + monkeypatch.setattr( + config, + "save_config", + Mock(), + ) + + result = config.update_config( + lambda cfg: cfg.update( + {"enabled": True} + ) + ) + + assert result["enabled"] is True + + +def test_update_config_can_delete_key( + monkeypatch, +): + monkeypatch.setattr( + config, + "load_config", + Mock( + return_value={ + "keep": 1, + "delete": 2, + } + ), + ) + + monkeypatch.setattr( + config, + "save_config", + Mock(), + ) + + def mutator(cfg): + del cfg["delete"] + + result = config.update_config(mutator) + + assert result == { + "keep": 1, + } + + +def test_update_config_does_not_save_if_mutator_raises( + monkeypatch, +): + monkeypatch.setattr( + config, + "load_config", + Mock(return_value={"a": 1}), + ) + + save = Mock() + + monkeypatch.setattr( + config, + "save_config", + save, + ) + + def broken_mutator(cfg): + raise RuntimeError("mutation failed") + + with pytest.raises( + RuntimeError, + match="mutation failed", + ): + config.update_config(broken_mutator) + + save.assert_not_called() + + +def test_update_config_propagates_mutator_exception( + monkeypatch, +): + monkeypatch.setattr( + config, + "load_config", + Mock(return_value={}), + ) + + monkeypatch.setattr( + config, + "save_config", + Mock(), + ) + + def mutator(cfg): + raise ValueError("bad value") + + with pytest.raises( + ValueError, + match="bad value", + ): + config.update_config(mutator) + + +def test_update_config_with_real_file( + tmp_path, + monkeypatch, +): + path = tmp_path / "config.json" + + path.write_text( + json.dumps({ + "existing": "value", + }) + ) + + monkeypatch.setattr( + config, + "CONFIG_FILE", + str(path), + ) + + result = config.update_config( + lambda cfg: cfg.update({ + "new": "value", + }) + ) + + expected = { + "existing": "value", + "new": "value", + } + + assert result == expected + assert json.loads(path.read_text()) == expected + + +# ============================================================================ +# Locking behavior +# ============================================================================ + +def test_config_lock_is_reentrant(): + """ + update_config() acquires _CONFIG_LOCK and then calls save_config(), + which acquires the same lock again. Therefore this must be an RLock. + """ + + assert config._CONFIG_LOCK.acquire( + blocking=False + ) + + try: + assert config._CONFIG_LOCK.acquire( + blocking=False + ) + + config._CONFIG_LOCK.release() + + finally: + config._CONFIG_LOCK.release() + + +def test_update_config_holds_lock_during_load_mutate_save( + monkeypatch, +): + events = [] + + class RecordingLock: + def __enter__(self): + events.append("lock-enter") + + def __exit__(self, exc_type, exc, tb): + events.append("lock-exit") + + monkeypatch.setattr( + config, + "_CONFIG_LOCK", + RecordingLock(), + ) + + def load(): + events.append("load") + return {} + + def mutate(cfg): + events.append("mutate") + cfg["x"] = 1 + + def save(cfg): + events.append("save") + + monkeypatch.setattr( + config, + "load_config", + load, + ) + + monkeypatch.setattr( + config, + "save_config", + save, + ) + + config.update_config(mutate) + + assert events == [ + "lock-enter", + "load", + "mutate", + "save", + "lock-exit", + ] + + +# ============================================================================ +# _relocate_legacy_data +# ============================================================================ + +def test_relocate_legacy_data_moves_file_from_app_dir( + tmp_path, + monkeypatch, +): app_dir = tmp_path / "app" data_dir = tmp_path / "data" config_dir = tmp_path / "config" @@ -128,19 +888,321 @@ def test_relocate_legacy_data(monkeypatch, tmp_path): data_dir.mkdir() config_dir.mkdir() - old_config = app_dir / "octo_updater_config.json" - new_config = config_dir / "config.json" + old = app_dir / "octo_updater_config.json" + new = config_dir / "config.json" - old_config.write_text('{"legacy": true}') + old.write_text('{"legacy": true}') - monkeypatch.setattr(config, "APP_DIR", str(app_dir)) - monkeypatch.setattr(config, "APP_DATA_DIR", str(data_dir)) - monkeypatch.setattr(config, "CONFIG_FILE", str(new_config)) + monkeypatch.setattr( + config, + "APP_DIR", + str(app_dir), + ) + + monkeypatch.setattr( + config, + "APP_DATA_DIR", + str(data_dir), + ) + + monkeypatch.setattr( + config, + "CONFIG_FILE", + str(new), + ) config._relocate_legacy_data() - assert not old_config.exists() - assert new_config.exists() - assert json.loads(new_config.read_text()) == { - "legacy": True + assert not old.exists() + assert new.exists() + assert json.loads(new.read_text()) == { + "legacy": True, + } + + +def test_relocate_legacy_data_moves_file_from_data_dir( + tmp_path, + monkeypatch, +): + app_dir = tmp_path / "app" + data_dir = tmp_path / "data" + config_dir = tmp_path / "config" + + app_dir.mkdir() + data_dir.mkdir() + config_dir.mkdir() + + old = data_dir / "octo_updater_config.json" + new = config_dir / "config.json" + + old.write_text('{"legacy": true}') + + monkeypatch.setattr( + config, + "APP_DIR", + str(app_dir), + ) + + monkeypatch.setattr( + config, + "APP_DATA_DIR", + str(data_dir), + ) + + monkeypatch.setattr( + config, + "CONFIG_FILE", + str(new), + ) + + config._relocate_legacy_data() + + assert not old.exists() + assert new.exists() + + +def test_relocate_legacy_data_prefers_app_dir_candidate( + tmp_path, + monkeypatch, +): + app_dir = tmp_path / "app" + data_dir = tmp_path / "data" + config_dir = tmp_path / "config" + + app_dir.mkdir() + data_dir.mkdir() + config_dir.mkdir() + + app_old = app_dir / "octo_updater_config.json" + data_old = data_dir / "octo_updater_config.json" + new = config_dir / "config.json" + + app_old.write_text('{"source": "app"}') + data_old.write_text('{"source": "data"}') + + monkeypatch.setattr( + config, + "APP_DIR", + str(app_dir), + ) + + monkeypatch.setattr( + config, + "APP_DATA_DIR", + str(data_dir), + ) + + monkeypatch.setattr( + config, + "CONFIG_FILE", + str(new), + ) + + config._relocate_legacy_data() + + assert json.loads(new.read_text()) == { + "source": "app", + } + + assert not app_old.exists() + + # Loop breaks after first successful migration. + assert data_old.exists() + + +def test_relocate_legacy_data_does_nothing_when_no_legacy_file( + tmp_path, + monkeypatch, +): + app_dir = tmp_path / "app" + data_dir = tmp_path / "data" + config_dir = tmp_path / "config" + + app_dir.mkdir() + data_dir.mkdir() + config_dir.mkdir() + + new = config_dir / "config.json" + + monkeypatch.setattr( + config, + "APP_DIR", + str(app_dir), + ) + + monkeypatch.setattr( + config, + "APP_DATA_DIR", + str(data_dir), + ) + + monkeypatch.setattr( + config, + "CONFIG_FILE", + str(new), + ) + + config._relocate_legacy_data() + + assert not new.exists() + + +def test_relocate_legacy_data_does_not_overwrite_existing_config( + tmp_path, + monkeypatch, +): + app_dir = tmp_path / "app" + data_dir = tmp_path / "data" + config_dir = tmp_path / "config" + + app_dir.mkdir() + data_dir.mkdir() + config_dir.mkdir() + + old = app_dir / "octo_updater_config.json" + new = config_dir / "config.json" + + old.write_text('{"legacy": true}') + new.write_text('{"current": true}') + + monkeypatch.setattr( + config, + "APP_DIR", + str(app_dir), + ) + + monkeypatch.setattr( + config, + "APP_DATA_DIR", + str(data_dir), + ) + + monkeypatch.setattr( + config, + "CONFIG_FILE", + str(new), + ) + + config._relocate_legacy_data() + + assert old.exists() + + assert json.loads(new.read_text()) == { + "current": True, + } + + +def test_relocate_legacy_data_swallows_move_failure( + tmp_path, + monkeypatch, +): + app_dir = tmp_path / "app" + data_dir = tmp_path / "data" + + app_dir.mkdir() + data_dir.mkdir() + + old = app_dir / "octo_updater_config.json" + old.write_text("{}") + + monkeypatch.setattr( + config, + "APP_DIR", + str(app_dir), + ) + + monkeypatch.setattr( + config, + "APP_DATA_DIR", + str(data_dir), + ) + + monkeypatch.setattr( + config, + "CONFIG_FILE", + str(tmp_path / "config.json"), + ) + + move = Mock( + side_effect=OSError("move failed") + ) + + monkeypatch.setattr( + config.shutil, + "move", + move, + ) + + # Deliberately swallowed by _relocate_legacy_data(). + config._relocate_legacy_data() + + assert old.exists() + + +def test_relocate_legacy_data_tries_second_location_after_first_move_failure( + tmp_path, + monkeypatch, +): + app_dir = tmp_path / "app" + data_dir = tmp_path / "data" + config_dir = tmp_path / "config" + + app_dir.mkdir() + data_dir.mkdir() + config_dir.mkdir() + + app_old = app_dir / "octo_updater_config.json" + data_old = data_dir / "octo_updater_config.json" + new = config_dir / "config.json" + + app_old.write_text('{"source": "app"}') + data_old.write_text('{"source": "data"}') + + monkeypatch.setattr( + config, + "APP_DIR", + str(app_dir), + ) + + monkeypatch.setattr( + config, + "APP_DATA_DIR", + str(data_dir), + ) + + monkeypatch.setattr( + config, + "CONFIG_FILE", + str(new), + ) + + real_move = config.shutil.move + attempts = [] + + def fake_move(src, dst): + attempts.append(src) + + if src == str(app_old): + raise OSError("first location failed") + + return real_move(src, dst) + + monkeypatch.setattr( + config.shutil, + "move", + fake_move, + ) + + config._relocate_legacy_data() + + assert attempts == [ + str(app_old), + str(data_old), + ] + + assert app_old.exists() + assert not data_old.exists() + + assert json.loads(new.read_text()) == { + "source": "data", } diff --git a/src/tests/test_torrent.py b/src/tests/test_torrent.py index 6f7dd54..921b815 100644 --- a/src/tests/test_torrent.py +++ b/src/tests/test_torrent.py @@ -23,7 +23,7 @@ def test_ensure_aria2c_returns_existing_executable(monkeypatch): installer = Mock() monkeypatch.setattr( - torrent.platform_ops, + torrent, "install_aria2c_windows", installer, ) @@ -58,7 +58,7 @@ def test_ensure_aria2c_installs_when_not_found(monkeypatch): ) monkeypatch.setattr( - torrent.platform_ops, + torrent, "install_aria2c_windows", installer, ) @@ -86,7 +86,7 @@ def test_ensure_aria2c_passes_custom_logger(monkeypatch): ) monkeypatch.setattr( - torrent.platform_ops, + torrent, "install_aria2c_windows", installer, ) @@ -114,7 +114,7 @@ def test_ensure_aria2c_empty_path_triggers_install(monkeypatch): ) monkeypatch.setattr( - torrent.platform_ops, + torrent, "install_aria2c_windows", installer, ) @@ -133,7 +133,7 @@ def test_ensure_aria2c_propagates_installer_failure(monkeypatch): ) monkeypatch.setattr( - torrent.platform_ops, + torrent, "install_aria2c_windows", Mock( side_effect=RuntimeError( diff --git a/src/torrent.py b/src/torrent.py index 78e077f..8d0a48f 100644 --- a/src/torrent.py +++ b/src/torrent.py @@ -12,7 +12,7 @@ from .app_log import log from .config import APP_DATA_DIR, UA, DOWNLOAD_TIMEOUT from .mods import MODS_REGISTRY from .net import ALLOWED_DOWNLOAD_HOSTS, secure_urlopen -from .platform_ops import create_directory_link, ensure_dir +from .platform_ops import create_directory_link, ensure_dir, find_aria2c, install_aria2c_windows CLIENT_TORRENT_URL = "https://dl.octowow.st/download/client.torrent" @@ -35,9 +35,9 @@ def ensure_aria2c(log_fn=log) -> str: - return platform_ops.install_aria2c_windows( APP_DATA_DIR=APP_DATA_DIR, + return install_aria2c_windows( APP_DATA_DIR=APP_DATA_DIR, ALLOWED_DOWNLOAD_HOSTS=ALLOWED_DOWNLOAD_HOSTS, - log_fn=log) + log_fn=log_fn) def _bdecode(buf: bytes, pos: int = 0):