1414from dataclasses import dataclass
1515from functools import lru_cache
1616from pathlib import Path
17- from typing import Optional
17+ from typing import Callable , Optional , Protocol , Any
1818
1919from loguru import logger
2020from rich .console import Console
2727# Minimum rclone version for --create-empty-src-dirs support
2828MIN_RCLONE_VERSION_EMPTY_DIRS = (1 , 64 , 0 )
2929
30+ class RunResult (Protocol ):
31+ returncode : int
32+ stdout : str
33+
34+
35+ RunFunc = Callable [..., RunResult ]
36+ IsInstalledFunc = Callable [[], bool ]
37+
3038
3139class RcloneError (Exception ):
3240 """Exception raised for rclone command errors."""
3341
3442 pass
3543
3644
37- def check_rclone_installed () -> None :
45+ def check_rclone_installed (is_installed : IsInstalledFunc = is_rclone_installed ) -> None :
3846 """Check if rclone is installed and raise helpful error if not.
3947
4048 Raises:
4149 RcloneError: If rclone is not installed with installation instructions
4250 """
43- if not is_rclone_installed ():
51+ if not is_installed ():
4452 raise RcloneError (
4553 "rclone is not installed.\n \n "
4654 "Install rclone by running: bm cloud setup\n "
@@ -50,7 +58,7 @@ def check_rclone_installed() -> None:
5058
5159
5260@lru_cache (maxsize = 1 )
53- def get_rclone_version () -> tuple [int , int , int ] | None :
61+ def get_rclone_version (run : RunFunc = subprocess . run ) -> tuple [int , int , int ] | None :
5462 """Get rclone version as (major, minor, patch) tuple.
5563
5664 Returns:
@@ -60,7 +68,7 @@ def get_rclone_version() -> tuple[int, int, int] | None:
6068 Result is cached since rclone version won't change during runtime.
6169 """
6270 try :
63- result = subprocess . run (["rclone" , "version" ], capture_output = True , text = True , timeout = 10 )
71+ result = run (["rclone" , "version" ], capture_output = True , text = True , timeout = 10 )
6472 # Parse "rclone v1.64.2" or "rclone v1.60.1-DEV"
6573 match = re .search (r"v(\d+)\.(\d+)\.(\d+)" , result .stdout )
6674 if match :
@@ -72,13 +80,12 @@ def get_rclone_version() -> tuple[int, int, int] | None:
7280 return None
7381
7482
75- def supports_create_empty_src_dirs () -> bool :
83+ def supports_create_empty_src_dirs (version : tuple [ int , int , int ] | None ) -> bool :
7684 """Check if installed rclone supports --create-empty-src-dirs flag.
7785
7886 Returns:
7987 True if rclone version >= 1.64.0, False otherwise.
8088 """
81- version = get_rclone_version ()
8289 if version is None :
8390 # If we can't determine version, assume older and skip the flag
8491 return False
@@ -167,6 +174,10 @@ def project_sync(
167174 bucket_name : str ,
168175 dry_run : bool = False ,
169176 verbose : bool = False ,
177+ * ,
178+ run : RunFunc = subprocess .run ,
179+ is_installed : IsInstalledFunc = is_rclone_installed ,
180+ filter_path : Path | None = None ,
170181) -> bool :
171182 """One-way sync: local → cloud.
172183
@@ -184,14 +195,14 @@ def project_sync(
184195 Raises:
185196 RcloneError: If project has no local_sync_path configured or rclone not installed
186197 """
187- check_rclone_installed ()
198+ check_rclone_installed (is_installed = is_installed )
188199
189200 if not project .local_sync_path :
190201 raise RcloneError (f"Project { project .name } has no local_sync_path configured" )
191202
192203 local_path = Path (project .local_sync_path ).expanduser ()
193204 remote_path = get_project_remote (project , bucket_name )
194- filter_path = get_bmignore_filter_path ()
205+ filter_path = filter_path or get_bmignore_filter_path ()
195206
196207 cmd = [
197208 "rclone" ,
@@ -210,7 +221,7 @@ def project_sync(
210221 if dry_run :
211222 cmd .append ("--dry-run" )
212223
213- result = subprocess . run (cmd , text = True )
224+ result = run (cmd , text = True )
214225 return result .returncode == 0
215226
216227
@@ -220,6 +231,13 @@ def project_bisync(
220231 dry_run : bool = False ,
221232 resync : bool = False ,
222233 verbose : bool = False ,
234+ * ,
235+ run : RunFunc = subprocess .run ,
236+ is_installed : IsInstalledFunc = is_rclone_installed ,
237+ version : tuple [int , int , int ] | None = None ,
238+ filter_path : Path | None = None ,
239+ state_path : Path | None = None ,
240+ is_initialized : Callable [[str ], bool ] = bisync_initialized ,
223241) -> bool :
224242 """Two-way sync: local ↔ cloud.
225243
@@ -242,15 +260,15 @@ def project_bisync(
242260 Raises:
243261 RcloneError: If project has no local_sync_path, needs --resync, or rclone not installed
244262 """
245- check_rclone_installed ()
263+ check_rclone_installed (is_installed = is_installed )
246264
247265 if not project .local_sync_path :
248266 raise RcloneError (f"Project { project .name } has no local_sync_path configured" )
249267
250268 local_path = Path (project .local_sync_path ).expanduser ()
251269 remote_path = get_project_remote (project , bucket_name )
252- filter_path = get_bmignore_filter_path ()
253- state_path = get_project_bisync_state (project .name )
270+ filter_path = filter_path or get_bmignore_filter_path ()
271+ state_path = state_path or get_project_bisync_state (project .name )
254272
255273 # Ensure state directory exists
256274 state_path .mkdir (parents = True , exist_ok = True )
@@ -271,7 +289,8 @@ def project_bisync(
271289 ]
272290
273291 # Add --create-empty-src-dirs if rclone version supports it (v1.64+)
274- if supports_create_empty_src_dirs ():
292+ version = version if version is not None else get_rclone_version (run = run )
293+ if supports_create_empty_src_dirs (version ):
275294 cmd .append ("--create-empty-src-dirs" )
276295
277296 if verbose :
@@ -286,20 +305,24 @@ def project_bisync(
286305 cmd .append ("--resync" )
287306
288307 # Check if first run requires resync
289- if not resync and not bisync_initialized (project .name ) and not dry_run :
308+ if not resync and not is_initialized (project .name ) and not dry_run :
290309 raise RcloneError (
291310 f"First bisync for { project .name } requires --resync to establish baseline.\n "
292311 f"Run: bm project bisync --name { project .name } --resync"
293312 )
294313
295- result = subprocess . run (cmd , text = True )
314+ result = run (cmd , text = True )
296315 return result .returncode == 0
297316
298317
299318def project_check (
300319 project : SyncProject ,
301320 bucket_name : str ,
302321 one_way : bool = False ,
322+ * ,
323+ run : RunFunc = subprocess .run ,
324+ is_installed : IsInstalledFunc = is_rclone_installed ,
325+ filter_path : Path | None = None ,
303326) -> bool :
304327 """Check integrity between local and cloud.
305328
@@ -316,14 +339,14 @@ def project_check(
316339 Raises:
317340 RcloneError: If project has no local_sync_path configured or rclone not installed
318341 """
319- check_rclone_installed ()
342+ check_rclone_installed (is_installed = is_installed )
320343
321344 if not project .local_sync_path :
322345 raise RcloneError (f"Project { project .name } has no local_sync_path configured" )
323346
324347 local_path = Path (project .local_sync_path ).expanduser ()
325348 remote_path = get_project_remote (project , bucket_name )
326- filter_path = get_bmignore_filter_path ()
349+ filter_path = filter_path or get_bmignore_filter_path ()
327350
328351 cmd = [
329352 "rclone" ,
@@ -337,14 +360,17 @@ def project_check(
337360 if one_way :
338361 cmd .append ("--one-way" )
339362
340- result = subprocess . run (cmd , capture_output = True , text = True )
363+ result = run (cmd , capture_output = True , text = True )
341364 return result .returncode == 0
342365
343366
344367def project_ls (
345368 project : SyncProject ,
346369 bucket_name : str ,
347370 path : Optional [str ] = None ,
371+ * ,
372+ run : RunFunc = subprocess .run ,
373+ is_installed : IsInstalledFunc = is_rclone_installed ,
348374) -> list [str ]:
349375 """List files in remote project.
350376
@@ -360,12 +386,12 @@ def project_ls(
360386 subprocess.CalledProcessError: If rclone command fails
361387 RcloneError: If rclone is not installed
362388 """
363- check_rclone_installed ()
389+ check_rclone_installed (is_installed = is_installed )
364390
365391 remote_path = get_project_remote (project , bucket_name )
366392 if path :
367393 remote_path = f"{ remote_path } /{ path } "
368394
369395 cmd = ["rclone" , "ls" , remote_path ]
370- result = subprocess . run (cmd , capture_output = True , text = True , check = True )
396+ result = run (cmd , capture_output = True , text = True , check = True )
371397 return result .stdout .splitlines ()
0 commit comments