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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ rclone_python/__pycache__/
rclone_python/scripts/__pycache__
tests/__pycache__
.vscode/
*.conf
*.conf
.venv/
32 changes: 30 additions & 2 deletions rclone_python/rclone.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
13 changes: 10 additions & 3 deletions rclone_python/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,19 @@ 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."""
if cls._instance is None:
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


Expand All @@ -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)
Expand Down
166 changes: 166 additions & 0 deletions tests/test_set_executable.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
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


@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(CUSTOM_PATH):
"""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(CUSTOM_PATH):
"""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(CUSTOM_PATH):
"""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
Loading