From 7c735e7f6dcefdd7e63af13ed54066521098718e Mon Sep 17 00:00:00 2001 From: Omkar Jagtap Date: Fri, 16 Jan 2026 02:30:56 +0530 Subject: [PATCH 1/3] Added feature to set exec path Now, we can set the custom executable path of rclone, which was previously using system path. Useful in many applications. --- tests/test_set_executable.py | 96 ++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/test_set_executable.py diff --git a/tests/test_set_executable.py b/tests/test_set_executable.py new file mode 100644 index 0000000..255a2a6 --- /dev/null +++ b/tests/test_set_executable.py @@ -0,0 +1,96 @@ +import pytest +from unittest.mock import patch, MagicMock +from rclone_python import rclone, utils + +# Change this as per your test environment +CUSTOM_PATH = "../exec/rclone.exe" + +@pytest.fixture(autouse=True) +def reset_config(): + """Ensure the singleton Config is reset to defaults before each test.""" + cfg = utils.Config() + cfg.config_path = None + cfg.executable_path = "rclone" + yield + # Reset again after test to avoid leak across files + cfg.config_path = None + cfg.executable_path = "rclone" + + +def test_set_executable_file(): + """Test that set_executable_file correctly sets the executable path.""" + + # Set custom executable + rclone.set_executable_file(CUSTOM_PATH) + + # Get the config instance and verify the executable path was set + config = utils.Config() + assert config.executable_path == CUSTOM_PATH + + +def test_default_executable_path(): + """Test that the default executable path is 'rclone'.""" + config = utils.Config() + # The default should be "rclone" + assert config.executable_path == "rclone" + + +def test_set_executable_file_validation(): + """Test that set_executable_file validates the path when validate=True.""" + + # Should raise FileNotFoundError for non-existent path + with pytest.raises(FileNotFoundError): + rclone.set_executable_file("/non/existent/path/rclone") + + # Should not raise when validate=False + rclone.set_executable_file("/non/existent/path/rclone", validate=False) + config = utils.Config() + assert config.executable_path == "/non/existent/path/rclone" + + +def test_is_installed_with_custom_executable(tmp_path): + """Test that is_installed() returns True when a valid custom executable is set.""" + + # Create a fake executable file + fake_rclone = tmp_path / "rclone.exe" + fake_rclone.write_text("fake executable") + + # Mock which to return None (rclone not in PATH) + with patch('rclone_python.rclone.which', return_value=None): + # Without custom path, should return False + assert rclone.is_installed() == False + + # Set custom executable path (skip validation since it's not a real executable) + rclone.set_executable_file(str(fake_rclone), validate=False) + + # Now should return True + assert rclone.is_installed() == True + + +def test_executable_used_in_command(): + """Test that the custom executable is actually used when running rclone commands.""" + + # Set custom executable (skip validation for mock path) + rclone.set_executable_file(CUSTOM_PATH, validate=False) + + # Mock subprocess.run to capture the command being executed and bypass rclone presence check + with patch('rclone_python.rclone.is_installed', return_value=True), \ + patch('rclone_python.utils.subprocess.run') as mock_run: + # Setup mock to return successful result + mock_process = MagicMock() + mock_process.returncode = 0 + mock_process.stdout = "[]" + mock_process.stderr = "" + mock_run.return_value = mock_process + + rclone.get_remotes() + + # Verify that subprocess.run was called + assert mock_run.called + + # Get the command that was executed + call_args = mock_run.call_args + executed_command = call_args[0][0] + + # Verify the custom executable is in the command + assert CUSTOM_PATH in executed_command \ No newline at end of file From 9b1544178c0f7ee1452ba540249143fb70e5989c Mon Sep 17 00:00:00 2001 From: Omkar Jagtap Date: Fri, 16 Jan 2026 02:35:37 +0530 Subject: [PATCH 2/3] Added remaining files --- .gitignore | 3 ++- rclone_python/rclone.py | 32 ++++++++++++++++++++++++++++++-- rclone_python/utils.py | 13 ++++++++++--- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index b2bed16..b51e4e9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ rclone_python/__pycache__/ rclone_python/scripts/__pycache__ tests/__pycache__ .vscode/ -*.conf \ No newline at end of file +*.conf +.venv/ \ No newline at end of file diff --git a/rclone_python/rclone.py b/rclone_python/rclone.py index da399b4..ab23ffd 100644 --- a/rclone_python/rclone.py +++ b/rclone_python/rclone.py @@ -30,6 +30,25 @@ def set_config_file(config_file: str): utils.Config(config_file) +def set_executable_file(executable_file: str, validate: bool = True): + """Change the rclone executable path used by this wrapper. + + Args: + executable_file (str): The path to the rclone executable. + validate (bool): If True, validates that the path exists. Defaults to True. + + Raises: + FileNotFoundError: If validate is True and the executable path does not exist. + """ + if validate: + path = Path(executable_file) + if not path.is_file(): + raise FileNotFoundError(f"Executable path '{executable_file}' does not exist") + + config = utils.Config() + config.executable_path = executable_file + + def set_log_level(level: int): """Change the log level of this wrapper. @@ -41,9 +60,18 @@ def set_log_level(level: int): def is_installed() -> bool: """ - :return: True if rclone is correctly installed on the system. + :return: True if rclone is correctly installed on the system or a valid custom executable path is set. """ - return which("rclone") is not None + # Check if rclone is in system PATH + if which("rclone") is not None: + return True + + # Check if a custom executable path was set + config = utils.Config() + if config.executable_path != "rclone": + return Path(config.executable_path).is_file() + + return False @__check_installed diff --git a/rclone_python/utils.py b/rclone_python/utils.py index d4a8413..988878a 100644 --- a/rclone_python/utils.py +++ b/rclone_python/utils.py @@ -34,6 +34,7 @@ class Config: _instance = None _initialized = False config_path = None + executable_path = "rclone" def __new__(cls, *args, **kwargs): """Create a new instance of the Config class if it doesn't exist yet.""" @@ -41,9 +42,11 @@ def __new__(cls, *args, **kwargs): cls._instance = super().__new__(cls) return cls._instance - def __init__(self, config_path: Union[str, Path] = None): + def __init__(self, config_path: Union[str, Path] = None, executable_path: Union[str, Path] = None): if not self._initialized: self.config_path = config_path + if executable_path is not None: + self.executable_path = executable_path self.__class__._initialized = True @@ -65,10 +68,14 @@ def run_rclone_cmd( # Set the config path if defined by the user, # otherwise the default rclone config path is used: config = Config() + + # Use custom executable path if defined, otherwise use default "rclone" + executable = config.executable_path + if config.config_path is not None: - base_command = f"rclone --config={config.config_path}" + base_command = f"{executable} --config={config.config_path}" else: - base_command = "rclone" + base_command = executable # add optional arguments and flags to the command args_str = args2string(args) From 00a3100f009be33aee9c296547149feca0b11746 Mon Sep 17 00:00:00 2001 From: Omkar Jagtap Date: Mon, 26 Jan 2026 15:22:59 +0530 Subject: [PATCH 3/3] Fixed test file for set_executable_file --- tests/test_set_executable.py | 80 +++++++++++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 5 deletions(-) diff --git a/tests/test_set_executable.py b/tests/test_set_executable.py index 255a2a6..30845d9 100644 --- a/tests/test_set_executable.py +++ b/tests/test_set_executable.py @@ -1,12 +1,82 @@ import pytest +import platform +import zipfile +import tarfile +import urllib.request +import os from unittest.mock import patch, MagicMock from rclone_python import rclone, utils -# Change this as per your test environment -CUSTOM_PATH = "../exec/rclone.exe" + +@pytest.fixture(scope="module") +def CUSTOM_PATH(tmp_path_factory): + """Download and extract rclone binary to a temporary directory.""" + # Create a temporary directory for the rclone binary + temp_dir = tmp_path_factory.mktemp("rclone_bin") + + # Determine platform and architecture + system = platform.system().lower() + machine = platform.machine().lower() + + # Map architecture names + if machine in ("x86_64", "amd64"): + arch = "amd64" + elif machine in ("aarch64", "arm64"): + arch = "arm64" + elif machine in ("i386", "i686", "x86"): + arch = "386" + else: + arch = "amd64" # Default to amd64 + + # Determine download URL and file extension + if system == "windows": + os_name = "windows" + ext = "zip" + exe_name = "rclone.exe" + elif system == "darwin": + os_name = "osx" + ext = "zip" + exe_name = "rclone" + else: + os_name = "linux" + ext = "zip" + exe_name = "rclone" + + # Download URL for latest stable version + download_url = f"https://downloads.rclone.org/rclone-current-{os_name}-{arch}.{ext}" + archive_path = temp_dir / f"rclone.{ext}" + + # Download the archive + print(f"Downloading rclone from {download_url}...") + urllib.request.urlretrieve(download_url, archive_path) + + # Extract the archive + print(f"Extracting rclone to {temp_dir}...") + if ext == "zip": + with zipfile.ZipFile(archive_path, 'r') as zip_ref: + zip_ref.extractall(temp_dir) + else: + with tarfile.open(archive_path, 'r:gz') as tar_ref: + tar_ref.extractall(temp_dir) + + # Find the extracted rclone binary + # The archive extracts to a folder like "rclone-v1.xx.x-os-arch" + for item in temp_dir.iterdir(): + if item.is_dir() and item.name.startswith("rclone"): + binary_path = item / exe_name + if binary_path.exists(): + # Make executable on Unix systems + if system != "windows": + os.chmod(binary_path, 0o755) + print(f"Rclone binary available at: {binary_path}") + yield str(binary_path) + return + + raise FileNotFoundError("Could not find rclone binary in extracted archive") + @pytest.fixture(autouse=True) -def reset_config(): +def reset_config(CUSTOM_PATH): """Ensure the singleton Config is reset to defaults before each test.""" cfg = utils.Config() cfg.config_path = None @@ -17,7 +87,7 @@ def reset_config(): cfg.executable_path = "rclone" -def test_set_executable_file(): +def test_set_executable_file(CUSTOM_PATH): """Test that set_executable_file correctly sets the executable path.""" # Set custom executable @@ -67,7 +137,7 @@ def test_is_installed_with_custom_executable(tmp_path): assert rclone.is_installed() == True -def test_executable_used_in_command(): +def test_executable_used_in_command(CUSTOM_PATH): """Test that the custom executable is actually used when running rclone commands.""" # Set custom executable (skip validation for mock path)