experimaestro 2.0.0b8__py3-none-any.whl → 2.0.0b17__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of experimaestro might be problematic. Click here for more details.

Files changed (152) hide show
  1. experimaestro/__init__.py +12 -5
  2. experimaestro/cli/__init__.py +239 -126
  3. experimaestro/cli/filter.py +48 -23
  4. experimaestro/cli/jobs.py +253 -71
  5. experimaestro/cli/refactor.py +1 -2
  6. experimaestro/commandline.py +7 -4
  7. experimaestro/connectors/__init__.py +9 -1
  8. experimaestro/connectors/local.py +43 -3
  9. experimaestro/core/arguments.py +18 -18
  10. experimaestro/core/identifier.py +11 -11
  11. experimaestro/core/objects/config.py +96 -39
  12. experimaestro/core/objects/config_walk.py +3 -3
  13. experimaestro/core/{subparameters.py → partial.py} +16 -16
  14. experimaestro/core/partial_lock.py +394 -0
  15. experimaestro/core/types.py +12 -15
  16. experimaestro/dynamic.py +290 -0
  17. experimaestro/experiments/__init__.py +6 -2
  18. experimaestro/experiments/cli.py +217 -50
  19. experimaestro/experiments/configuration.py +24 -0
  20. experimaestro/generators.py +5 -5
  21. experimaestro/ipc.py +118 -1
  22. experimaestro/launcherfinder/__init__.py +2 -2
  23. experimaestro/launcherfinder/registry.py +6 -7
  24. experimaestro/launcherfinder/specs.py +2 -9
  25. experimaestro/launchers/slurm/__init__.py +2 -2
  26. experimaestro/launchers/slurm/base.py +62 -0
  27. experimaestro/locking.py +957 -1
  28. experimaestro/notifications.py +89 -201
  29. experimaestro/progress.py +63 -366
  30. experimaestro/rpyc.py +0 -2
  31. experimaestro/run.py +29 -2
  32. experimaestro/scheduler/__init__.py +8 -1
  33. experimaestro/scheduler/base.py +629 -53
  34. experimaestro/scheduler/dependencies.py +20 -16
  35. experimaestro/scheduler/experiment.py +732 -167
  36. experimaestro/scheduler/interfaces.py +316 -101
  37. experimaestro/scheduler/jobs.py +58 -20
  38. experimaestro/scheduler/remote/adaptive_sync.py +265 -0
  39. experimaestro/scheduler/remote/client.py +171 -117
  40. experimaestro/scheduler/remote/protocol.py +8 -193
  41. experimaestro/scheduler/remote/server.py +95 -71
  42. experimaestro/scheduler/services.py +53 -28
  43. experimaestro/scheduler/state_provider.py +663 -2430
  44. experimaestro/scheduler/state_status.py +1247 -0
  45. experimaestro/scheduler/transient.py +31 -0
  46. experimaestro/scheduler/workspace.py +1 -1
  47. experimaestro/scheduler/workspace_state_provider.py +1273 -0
  48. experimaestro/scriptbuilder.py +4 -4
  49. experimaestro/settings.py +36 -0
  50. experimaestro/tests/conftest.py +33 -5
  51. experimaestro/tests/connectors/bin/executable.py +1 -1
  52. experimaestro/tests/fixtures/pre_experiment/experiment_check_env.py +16 -0
  53. experimaestro/tests/fixtures/pre_experiment/experiment_check_mock.py +14 -0
  54. experimaestro/tests/fixtures/pre_experiment/experiment_simple.py +12 -0
  55. experimaestro/tests/fixtures/pre_experiment/pre_setup_env.py +5 -0
  56. experimaestro/tests/fixtures/pre_experiment/pre_setup_error.py +3 -0
  57. experimaestro/tests/fixtures/pre_experiment/pre_setup_mock.py +8 -0
  58. experimaestro/tests/launchers/bin/test.py +1 -0
  59. experimaestro/tests/launchers/test_slurm.py +9 -9
  60. experimaestro/tests/partial_reschedule.py +46 -0
  61. experimaestro/tests/restart.py +3 -3
  62. experimaestro/tests/restart_main.py +1 -0
  63. experimaestro/tests/scripts/notifyandwait.py +1 -0
  64. experimaestro/tests/task_partial.py +38 -0
  65. experimaestro/tests/task_tokens.py +2 -2
  66. experimaestro/tests/tasks/test_dynamic.py +6 -6
  67. experimaestro/tests/test_dependencies.py +3 -3
  68. experimaestro/tests/test_deprecated.py +15 -15
  69. experimaestro/tests/test_dynamic_locking.py +317 -0
  70. experimaestro/tests/test_environment.py +24 -14
  71. experimaestro/tests/test_experiment.py +171 -36
  72. experimaestro/tests/test_identifier.py +25 -25
  73. experimaestro/tests/test_identifier_stability.py +3 -5
  74. experimaestro/tests/test_multitoken.py +2 -4
  75. experimaestro/tests/{test_subparameters.py → test_partial.py} +25 -25
  76. experimaestro/tests/test_partial_paths.py +81 -138
  77. experimaestro/tests/test_pre_experiment.py +219 -0
  78. experimaestro/tests/test_progress.py +2 -8
  79. experimaestro/tests/test_remote_state.py +560 -99
  80. experimaestro/tests/test_stray_jobs.py +261 -0
  81. experimaestro/tests/test_tasks.py +1 -2
  82. experimaestro/tests/test_token_locking.py +52 -67
  83. experimaestro/tests/test_tokens.py +5 -6
  84. experimaestro/tests/test_transient.py +225 -0
  85. experimaestro/tests/test_workspace_state_provider.py +768 -0
  86. experimaestro/tests/token_reschedule.py +1 -3
  87. experimaestro/tests/utils.py +2 -7
  88. experimaestro/tokens.py +227 -372
  89. experimaestro/tools/diff.py +1 -0
  90. experimaestro/tools/documentation.py +4 -5
  91. experimaestro/tools/jobs.py +1 -2
  92. experimaestro/tui/app.py +438 -1966
  93. experimaestro/tui/app.tcss +162 -0
  94. experimaestro/tui/dialogs.py +172 -0
  95. experimaestro/tui/log_viewer.py +253 -3
  96. experimaestro/tui/messages.py +137 -0
  97. experimaestro/tui/utils.py +54 -0
  98. experimaestro/tui/widgets/__init__.py +23 -0
  99. experimaestro/tui/widgets/experiments.py +468 -0
  100. experimaestro/tui/widgets/global_services.py +238 -0
  101. experimaestro/tui/widgets/jobs.py +972 -0
  102. experimaestro/tui/widgets/log.py +156 -0
  103. experimaestro/tui/widgets/orphans.py +363 -0
  104. experimaestro/tui/widgets/runs.py +185 -0
  105. experimaestro/tui/widgets/services.py +314 -0
  106. experimaestro/tui/widgets/stray_jobs.py +528 -0
  107. experimaestro/utils/__init__.py +1 -1
  108. experimaestro/utils/environment.py +105 -22
  109. experimaestro/utils/fswatcher.py +124 -0
  110. experimaestro/utils/jobs.py +1 -2
  111. experimaestro/utils/jupyter.py +1 -2
  112. experimaestro/utils/logging.py +72 -0
  113. experimaestro/version.py +2 -2
  114. experimaestro/webui/__init__.py +9 -0
  115. experimaestro/webui/app.py +117 -0
  116. experimaestro/{server → webui}/data/index.css +66 -11
  117. experimaestro/webui/data/index.css.map +1 -0
  118. experimaestro/{server → webui}/data/index.js +82763 -87217
  119. experimaestro/webui/data/index.js.map +1 -0
  120. experimaestro/webui/routes/__init__.py +5 -0
  121. experimaestro/webui/routes/auth.py +53 -0
  122. experimaestro/webui/routes/proxy.py +117 -0
  123. experimaestro/webui/server.py +200 -0
  124. experimaestro/webui/state_bridge.py +152 -0
  125. experimaestro/webui/websocket.py +413 -0
  126. {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/METADATA +5 -6
  127. experimaestro-2.0.0b17.dist-info/RECORD +219 -0
  128. experimaestro/cli/progress.py +0 -269
  129. experimaestro/scheduler/state.py +0 -75
  130. experimaestro/scheduler/state_db.py +0 -437
  131. experimaestro/scheduler/state_sync.py +0 -891
  132. experimaestro/server/__init__.py +0 -467
  133. experimaestro/server/data/index.css.map +0 -1
  134. experimaestro/server/data/index.js.map +0 -1
  135. experimaestro/tests/test_cli_jobs.py +0 -615
  136. experimaestro/tests/test_file_progress.py +0 -425
  137. experimaestro/tests/test_file_progress_integration.py +0 -477
  138. experimaestro/tests/test_state_db.py +0 -434
  139. experimaestro-2.0.0b8.dist-info/RECORD +0 -187
  140. /experimaestro/{server → webui}/data/1815e00441357e01619e.ttf +0 -0
  141. /experimaestro/{server → webui}/data/2463b90d9a316e4e5294.woff2 +0 -0
  142. /experimaestro/{server → webui}/data/2582b0e4bcf85eceead0.ttf +0 -0
  143. /experimaestro/{server → webui}/data/89999bdf5d835c012025.woff2 +0 -0
  144. /experimaestro/{server → webui}/data/914997e1bdfc990d0897.ttf +0 -0
  145. /experimaestro/{server → webui}/data/c210719e60948b211a12.woff2 +0 -0
  146. /experimaestro/{server → webui}/data/favicon.ico +0 -0
  147. /experimaestro/{server → webui}/data/index.html +0 -0
  148. /experimaestro/{server → webui}/data/login.html +0 -0
  149. /experimaestro/{server → webui}/data/manifest.json +0 -0
  150. {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/WHEEL +0 -0
  151. {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/entry_points.txt +0 -0
  152. {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/licenses/LICENSE +0 -0
@@ -9,11 +9,12 @@ from typing import TYPE_CHECKING, Iterator, List, Optional, Set
9
9
  import concurrent
10
10
 
11
11
  from experimaestro.core.objects import Config, ConfigWalkContext, WatchedOutput
12
- from experimaestro.notifications import LevelInformation, Reporter
12
+ from experimaestro.notifications import LevelInformation
13
13
 
14
14
  # from experimaestro.scheduler.base import Scheduler
15
15
  from experimaestro.scheduler.dependencies import Dependency, Resource
16
16
  from experimaestro.scheduler.workspace import RunMode, Workspace
17
+ from experimaestro.scheduler.transient import TransientMode
17
18
  from experimaestro.scheduler.interfaces import (
18
19
  BaseJob,
19
20
  JobState,
@@ -47,6 +48,7 @@ __all__ = [
47
48
  "JobStateError",
48
49
  "JobFailureStatus",
49
50
  "Job",
51
+ "TransientMode",
50
52
  ]
51
53
 
52
54
 
@@ -63,9 +65,40 @@ class JobLock(Lock):
63
65
 
64
66
 
65
67
  class JobDependency(Dependency):
68
+ origin: "Job"
69
+
66
70
  def __init__(self, job):
67
71
  super().__init__(job)
68
72
 
73
+ async def ensure_started(self):
74
+ """Ensure the dependency job is started.
75
+
76
+ If the dependency is a transient job that was skipped (state is UNSCHEDULED),
77
+ this will start it so it actually runs.
78
+ """
79
+ origin_job = self.origin
80
+ if (
81
+ origin_job.transient.is_transient
82
+ and origin_job.state == JobState.UNSCHEDULED
83
+ ):
84
+ # Transient job was skipped but now is needed - start it
85
+ from experimaestro.utils import logger
86
+
87
+ logger.info(
88
+ "Starting transient job %s (needed by dependent job)",
89
+ origin_job.identifier,
90
+ )
91
+ # Mark as needed so aio_submit won't skip it again
92
+ origin_job._needed_transient = True
93
+ # Mark as WAITING and start the job via aio_submit
94
+ # Use aio_submit (not aio_start) to properly handle all job lifecycle
95
+ origin_job.set_state(JobState.WAITING)
96
+ if origin_job.scheduler is not None:
97
+ # Create a new future for the job so aio_lock can wait on it
98
+ origin_job._future = asyncio.ensure_future(
99
+ origin_job.scheduler.aio_submit(origin_job)
100
+ )
101
+
69
102
  async def aio_lock(self, timeout: float = 0):
70
103
  """Acquire lock on job dependency by waiting for job to complete
71
104
 
@@ -81,6 +114,9 @@ class JobDependency(Dependency):
81
114
  "Job dependencies only support timeout=0 (wait indefinitely)"
82
115
  )
83
116
 
117
+ # Ensure the dependency job is started (handles transient jobs)
118
+ await self.ensure_started()
119
+
84
120
  # Wait for the job to finish
85
121
  if self.origin._future is None:
86
122
  raise RuntimeError(f"Job {self.origin} has no future - not submitted")
@@ -89,8 +125,7 @@ class JobDependency(Dependency):
89
125
  # Check if the job succeeded
90
126
  if self.origin.state != JobState.DONE:
91
127
  raise RuntimeError(
92
- f"Dependency job {self.origin.identifier} failed with state "
93
- f"{self.origin.state} for {self.target.identifier}"
128
+ f"Dependency job {self.origin.identifier} failed with state {self.origin.state} for {self.target.identifier}"
94
129
  )
95
130
 
96
131
  # Job succeeded, acquire and return the lock
@@ -113,6 +148,7 @@ class Job(BaseJob, Resource):
113
148
  launcher: "Launcher" = None,
114
149
  run_mode: RunMode = RunMode.NORMAL,
115
150
  max_retries: Optional[int] = None,
151
+ transient: TransientMode = TransientMode.NONE,
116
152
  ):
117
153
  from experimaestro.scheduler.base import Scheduler
118
154
 
@@ -150,6 +186,12 @@ class Job(BaseJob, Resource):
150
186
  self.max_retries = max_retries if max_retries is not None else 3
151
187
  self.retry_count = 0
152
188
 
189
+ # Transient mode for intermediary tasks
190
+ self.transient = transient if transient is not None else TransientMode.NONE
191
+ # Flag set when a transient job's mode is merged to non-transient,
192
+ # indicating the job should run even though it was originally transient
193
+ self._needed_transient = False
194
+
153
195
  # Watched outputs (stored for deferred registration with scheduler)
154
196
  self.watched_outputs: List["WatchedOutput"] = list(
155
197
  config.__xpm__.watched_outputs
@@ -233,7 +275,7 @@ class Job(BaseJob, Resource):
233
275
  logger.debug(
234
276
  "Job %s submitted, unfinished jobs for %s: %d",
235
277
  self.identifier[:8],
236
- xp.workdir.name,
278
+ xp.name,
237
279
  xp.unfinishedJobs,
238
280
  )
239
281
  elif not is_counted(new_state) and is_counted(old_state):
@@ -242,7 +284,7 @@ class Job(BaseJob, Resource):
242
284
  logger.debug(
243
285
  "Job %s finished, unfinished jobs for %s: %d",
244
286
  self.identifier[:8],
245
- xp.workdir.name,
287
+ xp.name,
246
288
  xp.unfinishedJobs,
247
289
  )
248
290
 
@@ -301,16 +343,6 @@ class Job(BaseJob, Resource):
301
343
  self._progress[-1].desc = desc
302
344
  self._progress[-1].progress = value
303
345
 
304
- # Notify listeners via scheduler's thread-safe mechanism
305
- self.scheduler.notify_job_state(self)
306
-
307
- def add_notification_server(self, server):
308
- """Adds a notification server"""
309
- key, baseurl = server.getNotificationSpec()
310
- dirpath = self.path / Reporter.NOTIFICATION_FOLDER
311
- dirpath.mkdir(exist_ok=True)
312
- (dirpath / key).write_text(f"{baseurl}/{self.identifier}")
313
-
314
346
  @property
315
347
  def ready(self):
316
348
  return self.state == JobState.READY
@@ -428,6 +460,12 @@ class Job(BaseJob, Resource):
428
460
  logger.info("Rotating log file %s -> %s", log_path.name, new_name)
429
461
  log_path.rename(new_path)
430
462
 
463
+ def process_state_dict(self) -> dict | None:
464
+ """Get process state as dictionary."""
465
+ if self._process is not None:
466
+ return self._process.tospec()
467
+ return None
468
+
431
469
  @property
432
470
  def basepath(self) -> Path:
433
471
  return self.jobpath / self.name
@@ -454,28 +492,28 @@ class JobContext(ConfigWalkContext):
454
492
  def task(self):
455
493
  return self.job.config
456
494
 
457
- def partial_path(self, subparameters, config) -> Path:
458
- """Returns the partial directory path for a given subparameters instance.
495
+ def partial_path(self, partial, config) -> Path:
496
+ """Returns the partial directory path for a given partial instance.
459
497
 
460
498
  The partial path structure is:
461
499
  WORKSPACE/partials/TASK_ID/SUBPARAM_NAME/PARTIAL_ID/
462
500
 
463
501
  Args:
464
- subparameters: The Subparameters instance defining which groups to exclude
502
+ partial: The Partial instance defining which groups to exclude
465
503
  config: The configuration to compute the partial identifier for
466
504
 
467
505
  Returns:
468
506
  The partial directory path.
469
507
  """
470
508
  # Compute partial identifier
471
- partial_id = config.__xpm__.get_partial_identifier(subparameters)
509
+ partial_id = config.__xpm__.get_partial_identifier(partial)
472
510
 
473
511
  # Build partial directory path
474
512
  task_id = str(config.__xpmtype__.identifier)
475
513
  return (
476
514
  self.job.workspace.partialspath
477
515
  / task_id
478
- / subparameters.name
516
+ / partial.name
479
517
  / partial_id.all.hex()
480
518
  )
481
519
 
@@ -0,0 +1,265 @@
1
+ """Adaptive file synchronization for remote monitoring
2
+
3
+ Provides background rsync with adaptive intervals based on change frequency.
4
+ - Minimum interval: 10 seconds (to avoid overloading)
5
+ - Maximum interval: 5 minutes (to ensure eventual updates)
6
+ - Adapts based on whether changes are detected
7
+ """
8
+
9
+ import logging
10
+ import threading
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Callable, Optional
14
+
15
+ logger = logging.getLogger("xpm.remote.sync")
16
+
17
+ # Sync interval limits (in seconds)
18
+ MIN_SYNC_INTERVAL = 10.0
19
+ MAX_SYNC_INTERVAL = 300.0 # 5 minutes
20
+ INITIAL_SYNC_INTERVAL = 15.0
21
+
22
+ # Interval adjustment factors
23
+ SPEEDUP_FACTOR = 0.7 # When changes detected, reduce interval
24
+ SLOWDOWN_FACTOR = 1.5 # When no changes, increase interval
25
+
26
+
27
+ class AdaptiveSynchronizer:
28
+ """Background synchronizer with adaptive intervals
29
+
30
+ Syncs a remote path periodically, adjusting the interval based on
31
+ whether changes are detected.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ sync_func: Callable[[str], Optional[Path]],
37
+ remote_path: str,
38
+ name: str = "",
39
+ on_sync_start: Optional[Callable[[], None]] = None,
40
+ on_sync_complete: Optional[Callable[[Path], None]] = None,
41
+ on_sync_error: Optional[Callable[[str], None]] = None,
42
+ ):
43
+ """Initialize the synchronizer
44
+
45
+ Args:
46
+ sync_func: Function to call for syncing (e.g., state_provider.sync_path)
47
+ remote_path: Remote path to sync
48
+ name: Human-readable name for logging (e.g., "job:task_id" or "service:tensorboard")
49
+ on_sync_start: Callback when sync starts
50
+ on_sync_complete: Callback when sync completes with local path
51
+ on_sync_error: Callback when sync fails with error message
52
+ """
53
+ self.sync_func = sync_func
54
+ self.remote_path = remote_path
55
+ self.name = name or remote_path
56
+ self.on_sync_start = on_sync_start
57
+ self.on_sync_complete = on_sync_complete
58
+ self.on_sync_error = on_sync_error
59
+ self._syncing = False
60
+
61
+ self._interval = INITIAL_SYNC_INTERVAL
62
+ self._running = False
63
+ self._thread: Optional[threading.Thread] = None
64
+ self._stop_event = threading.Event()
65
+
66
+ # Track file modification times to detect changes
67
+ self._last_mtimes: dict[str, float] = {}
68
+ self._local_path: Optional[Path] = None
69
+
70
+ @property
71
+ def interval(self) -> float:
72
+ """Current sync interval"""
73
+ return self._interval
74
+
75
+ @property
76
+ def local_path(self) -> Optional[Path]:
77
+ """Local path after sync (None if not synced yet)"""
78
+ return self._local_path
79
+
80
+ @property
81
+ def syncing(self) -> bool:
82
+ """Whether a sync is currently in progress"""
83
+ return self._syncing
84
+
85
+ def start(self) -> None:
86
+ """Start background syncing"""
87
+ if self._running:
88
+ return
89
+
90
+ self._running = True
91
+ self._stop_event.clear()
92
+ self._thread = threading.Thread(target=self._sync_loop, daemon=True)
93
+ self._thread.start()
94
+ logger.info(
95
+ "[%s] Started adaptive sync (path: %s)", self.name, self.remote_path
96
+ )
97
+
98
+ def stop(self) -> None:
99
+ """Stop background syncing"""
100
+ if not self._running:
101
+ return
102
+
103
+ self._running = False
104
+ self._stop_event.set()
105
+
106
+ if self._thread:
107
+ self._thread.join(timeout=2.0)
108
+ self._thread = None
109
+
110
+ logger.info("[%s] Stopped adaptive sync", self.name)
111
+
112
+ def sync_now(self) -> Optional[Path]:
113
+ """Perform an immediate sync (blocking)
114
+
115
+ Returns:
116
+ Local path if successful, None otherwise
117
+ """
118
+ return self._do_sync()
119
+
120
+ def _sync_loop(self) -> None:
121
+ """Background sync loop"""
122
+ # Do initial sync immediately
123
+ self._do_sync()
124
+
125
+ while self._running:
126
+ # Wait for interval or stop signal
127
+ if self._stop_event.wait(timeout=self._interval):
128
+ break # Stop requested
129
+
130
+ if not self._running:
131
+ break
132
+
133
+ self._do_sync()
134
+
135
+ def _do_sync(self) -> Optional[Path]:
136
+ """Perform a single sync operation"""
137
+ try:
138
+ self._syncing = True
139
+ if self.on_sync_start:
140
+ self.on_sync_start()
141
+
142
+ logger.info("[%s] Starting rsync for %s...", self.name, self.remote_path)
143
+ start_time = time.time()
144
+ local_path = self.sync_func(self.remote_path)
145
+ logger.info("[%s] sync_func returned: %s", self.name, local_path)
146
+ sync_duration = time.time() - start_time
147
+
148
+ if local_path:
149
+ self._local_path = local_path
150
+
151
+ # Check for changes
152
+ has_changes = self._check_for_changes(local_path)
153
+
154
+ # Adjust interval based on changes
155
+ if has_changes:
156
+ # Changes detected - sync more frequently
157
+ self._interval = max(
158
+ MIN_SYNC_INTERVAL,
159
+ self._interval * SPEEDUP_FACTOR,
160
+ )
161
+ # But ensure we don't sync faster than the sync takes
162
+ self._interval = max(self._interval, sync_duration * 2)
163
+ logger.info(
164
+ "[%s] Rsync completed in %.1fs (changes detected), "
165
+ "next sync in %.1fs",
166
+ self.name,
167
+ sync_duration,
168
+ self._interval,
169
+ )
170
+ else:
171
+ # No changes - sync less frequently
172
+ self._interval = min(
173
+ MAX_SYNC_INTERVAL,
174
+ self._interval * SLOWDOWN_FACTOR,
175
+ )
176
+ logger.info(
177
+ "[%s] Rsync completed in %.1fs (no changes), "
178
+ "next sync in %.1fs",
179
+ self.name,
180
+ sync_duration,
181
+ self._interval,
182
+ )
183
+
184
+ if self.on_sync_complete:
185
+ self.on_sync_complete(local_path)
186
+
187
+ return local_path
188
+ else:
189
+ logger.warning("[%s] Rsync returned no path", self.name)
190
+ if self.on_sync_error:
191
+ self.on_sync_error("Sync returned no path")
192
+ return None
193
+
194
+ except Exception as e:
195
+ logger.warning("[%s] Rsync failed: %s", self.name, e)
196
+ # On error, slow down to avoid hammering
197
+ self._interval = min(MAX_SYNC_INTERVAL, self._interval * 2)
198
+ logger.info(
199
+ "[%s] Next sync in %.1fs (after error)", self.name, self._interval
200
+ )
201
+
202
+ if self.on_sync_error:
203
+ self.on_sync_error(str(e))
204
+
205
+ return None
206
+ finally:
207
+ self._syncing = False
208
+
209
+ def _check_for_changes(self, local_path: Path) -> bool:
210
+ """Check if any files in the synced directory have changed
211
+
212
+ Returns:
213
+ True if changes detected, False otherwise
214
+ """
215
+ has_changes = False
216
+ current_mtimes: dict[str, float] = {}
217
+
218
+ try:
219
+ # Check all files in the directory
220
+ if local_path.is_dir():
221
+ for file_path in local_path.rglob("*"):
222
+ if file_path.is_file():
223
+ key = str(file_path)
224
+ try:
225
+ mtime = file_path.stat().st_mtime
226
+ current_mtimes[key] = mtime
227
+
228
+ if key not in self._last_mtimes:
229
+ # New file
230
+ has_changes = True
231
+ elif self._last_mtimes[key] != mtime:
232
+ # File modified
233
+ has_changes = True
234
+ except OSError:
235
+ pass
236
+ elif local_path.is_file():
237
+ key = str(local_path)
238
+ try:
239
+ mtime = local_path.stat().st_mtime
240
+ current_mtimes[key] = mtime
241
+
242
+ if key not in self._last_mtimes:
243
+ has_changes = True
244
+ elif self._last_mtimes[key] != mtime:
245
+ has_changes = True
246
+ except OSError:
247
+ pass
248
+
249
+ # Check for deleted files
250
+ if set(self._last_mtimes.keys()) != set(current_mtimes.keys()):
251
+ has_changes = True
252
+
253
+ self._last_mtimes = current_mtimes
254
+
255
+ except Exception as e:
256
+ logger.warning("Error checking for changes: %s", e)
257
+
258
+ return has_changes
259
+
260
+ def __enter__(self):
261
+ self.start()
262
+ return self
263
+
264
+ def __exit__(self, *args):
265
+ self.stop()