experimaestro 1.5.1__py3-none-any.whl → 2.0.0a8__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 (118) hide show
  1. experimaestro/__init__.py +14 -4
  2. experimaestro/__main__.py +3 -423
  3. experimaestro/annotations.py +14 -4
  4. experimaestro/cli/__init__.py +311 -0
  5. experimaestro/{filter.py → cli/filter.py} +23 -9
  6. experimaestro/cli/jobs.py +268 -0
  7. experimaestro/cli/progress.py +269 -0
  8. experimaestro/click.py +0 -35
  9. experimaestro/commandline.py +3 -7
  10. experimaestro/connectors/__init__.py +29 -14
  11. experimaestro/connectors/local.py +19 -10
  12. experimaestro/connectors/ssh.py +27 -8
  13. experimaestro/core/arguments.py +45 -3
  14. experimaestro/core/callbacks.py +52 -0
  15. experimaestro/core/context.py +8 -9
  16. experimaestro/core/identifier.py +310 -0
  17. experimaestro/core/objects/__init__.py +44 -0
  18. experimaestro/core/{objects.py → objects/config.py} +399 -772
  19. experimaestro/core/objects/config_utils.py +58 -0
  20. experimaestro/core/objects/config_walk.py +151 -0
  21. experimaestro/core/objects.pyi +15 -45
  22. experimaestro/core/serialization.py +63 -9
  23. experimaestro/core/serializers.py +1 -8
  24. experimaestro/core/types.py +104 -66
  25. experimaestro/experiments/cli.py +154 -72
  26. experimaestro/experiments/configuration.py +10 -1
  27. experimaestro/generators.py +6 -1
  28. experimaestro/ipc.py +4 -1
  29. experimaestro/launcherfinder/__init__.py +1 -1
  30. experimaestro/launcherfinder/base.py +2 -18
  31. experimaestro/launcherfinder/parser.py +8 -3
  32. experimaestro/launcherfinder/registry.py +52 -140
  33. experimaestro/launcherfinder/specs.py +49 -10
  34. experimaestro/launchers/direct.py +0 -47
  35. experimaestro/launchers/slurm/base.py +54 -14
  36. experimaestro/mkdocs/__init__.py +1 -1
  37. experimaestro/mkdocs/base.py +6 -8
  38. experimaestro/notifications.py +38 -12
  39. experimaestro/progress.py +406 -0
  40. experimaestro/run.py +24 -3
  41. experimaestro/scheduler/__init__.py +18 -1
  42. experimaestro/scheduler/base.py +108 -808
  43. experimaestro/scheduler/dynamic_outputs.py +184 -0
  44. experimaestro/scheduler/experiment.py +387 -0
  45. experimaestro/scheduler/jobs.py +475 -0
  46. experimaestro/scheduler/signal_handler.py +32 -0
  47. experimaestro/scheduler/state.py +75 -0
  48. experimaestro/scheduler/workspace.py +27 -8
  49. experimaestro/scriptbuilder.py +18 -3
  50. experimaestro/server/__init__.py +36 -5
  51. experimaestro/server/data/1815e00441357e01619e.ttf +0 -0
  52. experimaestro/server/data/2463b90d9a316e4e5294.woff2 +0 -0
  53. experimaestro/server/data/2582b0e4bcf85eceead0.ttf +0 -0
  54. experimaestro/server/data/89999bdf5d835c012025.woff2 +0 -0
  55. experimaestro/server/data/914997e1bdfc990d0897.ttf +0 -0
  56. experimaestro/server/data/c210719e60948b211a12.woff2 +0 -0
  57. experimaestro/server/data/index.css +5187 -5068
  58. experimaestro/server/data/index.css.map +1 -1
  59. experimaestro/server/data/index.js +68887 -68064
  60. experimaestro/server/data/index.js.map +1 -1
  61. experimaestro/settings.py +45 -5
  62. experimaestro/sphinx/__init__.py +7 -17
  63. experimaestro/taskglobals.py +7 -2
  64. experimaestro/tests/core/__init__.py +0 -0
  65. experimaestro/tests/core/test_generics.py +206 -0
  66. experimaestro/tests/definitions_types.py +5 -3
  67. experimaestro/tests/launchers/bin/sbatch +34 -7
  68. experimaestro/tests/launchers/bin/srun +5 -0
  69. experimaestro/tests/launchers/common.py +17 -5
  70. experimaestro/tests/launchers/config_slurm/launchers.py +25 -0
  71. experimaestro/tests/restart.py +10 -5
  72. experimaestro/tests/tasks/all.py +23 -10
  73. experimaestro/tests/tasks/foreign.py +2 -4
  74. experimaestro/tests/test_checkers.py +2 -2
  75. experimaestro/tests/test_dependencies.py +11 -17
  76. experimaestro/tests/test_experiment.py +73 -0
  77. experimaestro/tests/test_file_progress.py +425 -0
  78. experimaestro/tests/test_file_progress_integration.py +477 -0
  79. experimaestro/tests/test_findlauncher.py +12 -5
  80. experimaestro/tests/test_forward.py +5 -5
  81. experimaestro/tests/test_generators.py +93 -0
  82. experimaestro/tests/test_identifier.py +182 -158
  83. experimaestro/tests/test_instance.py +19 -27
  84. experimaestro/tests/test_objects.py +13 -20
  85. experimaestro/tests/test_outputs.py +6 -6
  86. experimaestro/tests/test_param.py +68 -30
  87. experimaestro/tests/test_progress.py +4 -4
  88. experimaestro/tests/test_serializers.py +24 -64
  89. experimaestro/tests/test_ssh.py +7 -0
  90. experimaestro/tests/test_tags.py +50 -21
  91. experimaestro/tests/test_tasks.py +42 -51
  92. experimaestro/tests/test_tokens.py +11 -8
  93. experimaestro/tests/test_types.py +24 -21
  94. experimaestro/tests/test_validation.py +67 -110
  95. experimaestro/tests/token_reschedule.py +1 -1
  96. experimaestro/tokens.py +24 -13
  97. experimaestro/tools/diff.py +8 -1
  98. experimaestro/typingutils.py +20 -11
  99. experimaestro/utils/asyncio.py +6 -2
  100. experimaestro/utils/multiprocessing.py +44 -0
  101. experimaestro/utils/resources.py +11 -3
  102. {experimaestro-1.5.1.dist-info → experimaestro-2.0.0a8.dist-info}/METADATA +28 -36
  103. experimaestro-2.0.0a8.dist-info/RECORD +166 -0
  104. {experimaestro-1.5.1.dist-info → experimaestro-2.0.0a8.dist-info}/WHEEL +1 -1
  105. {experimaestro-1.5.1.dist-info → experimaestro-2.0.0a8.dist-info}/entry_points.txt +0 -4
  106. experimaestro/launchers/slurm/cli.py +0 -29
  107. experimaestro/launchers/slurm/configuration.py +0 -597
  108. experimaestro/scheduler/environment.py +0 -94
  109. experimaestro/server/data/016b4a6cdced82ab3aa1.ttf +0 -0
  110. experimaestro/server/data/50701fbb8177c2dde530.ttf +0 -0
  111. experimaestro/server/data/878f31251d960bd6266f.woff2 +0 -0
  112. experimaestro/server/data/b041b1fa4fe241b23445.woff2 +0 -0
  113. experimaestro/server/data/b6879d41b0852f01ed5b.woff2 +0 -0
  114. experimaestro/server/data/d75e3fd1eb12e9bd6655.ttf +0 -0
  115. experimaestro/tests/launchers/config_slurm/launchers.yaml +0 -134
  116. experimaestro/utils/yaml.py +0 -202
  117. experimaestro-1.5.1.dist-info/RECORD +0 -148
  118. {experimaestro-1.5.1.dist-info → experimaestro-2.0.0a8.dist-info/licenses}/LICENSE +0 -0
@@ -10,7 +10,7 @@ def test_choices():
10
10
  class TestChoices(Config):
11
11
  a: Annotated[str, Choices(["a", "b"])]
12
12
 
13
- TestChoices(a="a").__xpm__.validate()
13
+ TestChoices.C(a="a").__xpm__.validate()
14
14
 
15
15
  with pytest.raises((ValueError, KeyError)):
16
- TestChoices(a="c").__xpm__.validate()
16
+ TestChoices.C(a="c").__xpm__.validate()
@@ -1,7 +1,7 @@
1
1
  from typing import Any, Callable
2
2
  import pytest
3
3
  from experimaestro import Config, Param, Task, RunMode
4
- from experimaestro.scheduler.base import JobDependency
4
+ from experimaestro.scheduler.jobs import JobDependency
5
5
  from experimaestro.tests.utils import TemporaryExperiment
6
6
 
7
7
 
@@ -35,21 +35,21 @@ class TaskB(Task):
35
35
 
36
36
 
37
37
  def test_dependencies_simple(xp):
38
- a = TaskA().submit()
39
- b = TaskB(a=a).submit()
38
+ a = TaskA.C().submit()
39
+ b = TaskB.C(a=a).submit()
40
40
  check_dependencies(b, a)
41
41
 
42
42
 
43
43
  def test_dependencies_implicit(xp):
44
- a = TaskA().submit()
45
- b = TaskB(a=a)
44
+ a = TaskA.C().submit()
45
+ b = TaskB.C(a=a)
46
46
  b.submit()
47
47
  check_dependencies(b, a)
48
48
 
49
49
 
50
50
  class TaskC(Task):
51
51
  def task_outputs(self, dep: Callable[[Config], None]) -> Any:
52
- return dep(ConfigC(param_c=self))
52
+ return dep(ConfigC.C(param_c=self))
53
53
 
54
54
 
55
55
  class ConfigC(Config):
@@ -61,15 +61,15 @@ class TaskD(Task):
61
61
 
62
62
 
63
63
  def test_dependencies_task_output(xp):
64
- task_c = TaskC()
64
+ task_c = TaskC.C()
65
65
  c = task_c.submit()
66
- d = TaskD(param_c=c).submit()
66
+ d = TaskD.C(param_c=c).submit()
67
67
  check_dependencies(d, task_c)
68
68
 
69
69
 
70
70
  class Inner_TaskA(Task):
71
71
  def task_outputs(self, dep: Callable[[Config], None]) -> Any:
72
- return dep(Inner_OutputTaskA())
72
+ return dep(Inner_OutputTaskA.C())
73
73
 
74
74
 
75
75
  class Inner_OutputTaskA(Config):
@@ -81,13 +81,7 @@ class Inner_TaskB(Task):
81
81
 
82
82
 
83
83
  def test_dependencies_inner_task_output(xp):
84
- task_a = Inner_TaskA()
84
+ task_a = Inner_TaskA.C()
85
85
  a = task_a.submit()
86
- b = Inner_TaskB(param_a=a).submit()
86
+ b = Inner_TaskB.C(param_a=a).submit()
87
87
  check_dependencies(b, task_a)
88
-
89
-
90
- def test_dependencies_pre_task(xp):
91
- a = TaskA().submit()
92
- a2 = TaskA().add_pretasks(a).submit()
93
- check_dependencies(a2, a)
@@ -0,0 +1,73 @@
1
+ from experimaestro import Task, Param, get_experiment, tag
2
+ from experimaestro.tests.utils import TemporaryDirectory, TemporaryExperiment
3
+
4
+
5
+ class TaskA(Task):
6
+ def execute(self):
7
+ pass
8
+
9
+
10
+ class TaskB(Task):
11
+ task_a: Param[TaskA]
12
+ x: Param[int]
13
+
14
+ def execute(self):
15
+ pass
16
+
17
+
18
+ # xp = get_experiment(id="my-xp-1")
19
+
20
+ # # Returns a list of tasks which were submitted and successful
21
+ # tasks = xp.get_tasks(myxps.evaluation.Evaluation, status=Job.DONE)
22
+
23
+ # for task in tasks:
24
+ # # Look at the tags
25
+ # print(task.tags)
26
+
27
+ # # Get some information
28
+ # print("Task ran in {task.workdir}")
29
+
30
+ # # Look at the parent jobs
31
+ # print(task.depends_on)
32
+
33
+ # # Look at the dependant
34
+ # print(task.dependents)
35
+
36
+
37
+ def test_experiment_history():
38
+ """Test retrieving experiment history"""
39
+ with TemporaryDirectory() as workdir:
40
+ with TemporaryExperiment("experiment", workdir=workdir):
41
+ task_a = TaskA.C().submit()
42
+ TaskB.C(task_a=task_a, x=tag(1)).submit()
43
+
44
+ # Look at the experiment
45
+ xp = get_experiment("experiment", workdir=workdir)
46
+
47
+ (task_a_info,) = xp.get_jobs(TaskA)
48
+ (task_b_info,) = xp.get_jobs(TaskB)
49
+ assert task_b_info.tags == {"x": 1}
50
+ assert task_b_info.depends_on == [task_a_info]
51
+
52
+
53
+ class FlagHandler:
54
+ def __init__(self):
55
+ self.flag = False
56
+
57
+ def set(self):
58
+ self.flag = True
59
+
60
+ def is_set(self):
61
+ return self.flag
62
+
63
+
64
+ def test_experiment_events():
65
+ """Test handlers"""
66
+
67
+ flag = FlagHandler()
68
+ with TemporaryExperiment("experiment"):
69
+ task_a = TaskA.C()
70
+ task_a.submit()
71
+ task_a.on_completed(flag.set)
72
+
73
+ assert flag.is_set()
@@ -0,0 +1,425 @@
1
+ """Tests for the file-based progress tracking system"""
2
+
3
+ import json
4
+ import tempfile
5
+ from pathlib import Path
6
+ from unittest.mock import patch
7
+
8
+ import pytest
9
+
10
+ from experimaestro.progress import (
11
+ ProgressEntry,
12
+ ProgressFileWriter,
13
+ ProgressFileReader,
14
+ FileBasedProgressReporter,
15
+ )
16
+
17
+
18
+ class TestProgressEntry:
19
+ """Test ProgressEntry dataclass"""
20
+
21
+ def test_to_dict(self):
22
+ entry = ProgressEntry(
23
+ timestamp=1234567890.0, level=1, progress=0.5, desc="Test description"
24
+ )
25
+
26
+ expected = {
27
+ "timestamp": 1234567890.0,
28
+ "level": 1,
29
+ "progress": 0.5,
30
+ "desc": "Test description",
31
+ }
32
+
33
+ assert entry.to_dict() == expected
34
+
35
+ def test_from_dict(self):
36
+ data = {
37
+ "timestamp": 1234567890.0,
38
+ "level": 1,
39
+ "progress": 0.5,
40
+ "desc": "Test description",
41
+ }
42
+
43
+ entry = ProgressEntry.from_dict(data)
44
+
45
+ assert entry.timestamp == 1234567890.0
46
+ assert entry.level == 1
47
+ assert entry.progress == 0.5
48
+ assert entry.desc == "Test description"
49
+
50
+ def test_from_dict_minimal(self):
51
+ data = {"timestamp": 1234567890.0, "level": 0, "progress": 1.0}
52
+
53
+ entry = ProgressEntry.from_dict(data)
54
+
55
+ assert entry.timestamp == 1234567890.0
56
+ assert entry.level == 0
57
+ assert entry.progress == 1.0
58
+ assert entry.desc is None
59
+
60
+
61
+ class TestProgressFileWriter:
62
+ """Test ProgressFileWriter class"""
63
+
64
+ def test_init_creates_directory(self):
65
+ with tempfile.TemporaryDirectory() as tmpdir:
66
+ task_path = Path(tmpdir)
67
+ writer = ProgressFileWriter(task_path)
68
+
69
+ assert writer.progress_dir.exists()
70
+ assert writer.progress_dir == task_path / ".experimaestro"
71
+
72
+ def test_write_single_progress(self):
73
+ with tempfile.TemporaryDirectory() as tmpdir:
74
+ task_path = Path(tmpdir)
75
+ writer = ProgressFileWriter(task_path)
76
+
77
+ writer.write_progress(0, 0.5, "Test progress")
78
+
79
+ # Check file was created
80
+ progress_file = writer.progress_dir / "progress-0000.jsonl"
81
+ assert progress_file.exists()
82
+
83
+ # Check symlink was created
84
+ latest_link = writer.progress_dir / "progress-latest.jsonl"
85
+ assert latest_link.exists()
86
+ assert latest_link.is_symlink()
87
+ assert latest_link.resolve().name == progress_file.name
88
+
89
+ def test_write_multiple_progress(self):
90
+ with tempfile.TemporaryDirectory() as tmpdir:
91
+ task_path = Path(tmpdir)
92
+ writer = ProgressFileWriter(task_path)
93
+
94
+ # Write multiple progress entries
95
+ writer.write_progress(0, 0.1, "Step 1")
96
+ writer.write_progress(0, 0.5, "Step 2")
97
+ writer.write_progress(1, 0.3, "Substep")
98
+ writer.write_progress(0, 1.0, "Complete")
99
+
100
+ progress_file = writer.progress_dir / "progress-0000.jsonl"
101
+ assert progress_file.exists()
102
+
103
+ # Read and verify entries
104
+ lines = progress_file.read_text().strip().split("\n")
105
+ assert len(lines) == 4
106
+
107
+ # Check first entry
108
+ entry1 = json.loads(lines[0])
109
+ assert entry1["level"] == 0
110
+ assert entry1["progress"] == 0.1
111
+ assert entry1["desc"] == "Step 1"
112
+
113
+ def test_file_rotation(self):
114
+ with tempfile.TemporaryDirectory() as tmpdir:
115
+ task_path = Path(tmpdir)
116
+ # Set small max entries for testing rotation
117
+ writer = ProgressFileWriter(task_path, max_entries_per_file=2)
118
+
119
+ # Write 3 entries to trigger rotation
120
+ writer.write_progress(0, 0.1, "Entry 1")
121
+ writer.write_progress(0, 0.2, "Entry 2")
122
+ writer.write_progress(0, 0.3, "Entry 3") # Should trigger rotation
123
+
124
+ # Check both files exist
125
+ file1 = writer.progress_dir / "progress-0000.jsonl"
126
+ file2 = writer.progress_dir / "progress-0001.jsonl"
127
+
128
+ assert file1.exists()
129
+ assert file2.exists()
130
+
131
+ # Check file1 has 2 entries
132
+ lines1 = file1.read_text().strip().split("\n")
133
+ assert len(lines1) == 2
134
+
135
+ # Check file2 has 1 entry
136
+ lines2 = file2.read_text().strip().split("\n")
137
+ assert len(lines2) == 1
138
+
139
+ # Check symlink points to latest file
140
+ latest_link = writer.progress_dir / "progress-latest.jsonl"
141
+ assert latest_link.resolve().name == file2.name
142
+
143
+ def test_resume_from_existing_files(self):
144
+ with tempfile.TemporaryDirectory() as tmpdir:
145
+ task_path = Path(tmpdir)
146
+
147
+ # Create first writer and write some entries
148
+ writer1 = ProgressFileWriter(task_path, max_entries_per_file=2)
149
+ writer1.write_progress(0, 0.1, "Entry 1")
150
+ writer1.write_progress(0, 0.2, "Entry 2")
151
+
152
+ # Create second writer (simulating restart)
153
+ writer2 = ProgressFileWriter(task_path, max_entries_per_file=2)
154
+
155
+ # Should resume from existing state
156
+ assert writer2.current_file_index == 0
157
+ assert writer2.current_file_entries == 2
158
+
159
+ # Writing one more should trigger rotation
160
+ writer2.write_progress(0, 0.3, "Entry 3")
161
+
162
+ file2 = writer2.progress_dir / "progress-0001.jsonl"
163
+ assert file2.exists()
164
+
165
+
166
+ class TestProgressFileReader:
167
+ """Test ProgressFileReader class"""
168
+
169
+ def test_read_entries_from_file(self):
170
+ with tempfile.TemporaryDirectory() as tmpdir:
171
+ task_path = Path(tmpdir)
172
+
173
+ # Write some test data
174
+ writer = ProgressFileWriter(task_path)
175
+ writer.write_progress(0, 0.5, "Test")
176
+ writer.write_progress(1, 0.3, "Nested")
177
+
178
+ # Read it back
179
+ reader = ProgressFileReader(task_path)
180
+ progress_file = reader.get_progress_files()[0]
181
+ entries = list(reader.read_entries(progress_file))
182
+
183
+ assert len(entries) == 2
184
+ assert entries[0].level == 0
185
+ assert entries[0].progress == 0.5
186
+ assert entries[0].desc == "Test"
187
+ assert entries[1].level == 1
188
+ assert entries[1].progress == 0.3
189
+ assert entries[1].desc == "Nested"
190
+
191
+ def test_read_all_entries(self):
192
+ with tempfile.TemporaryDirectory() as tmpdir:
193
+ task_path = Path(tmpdir)
194
+
195
+ # Write entries across multiple files
196
+ writer = ProgressFileWriter(task_path, max_entries_per_file=2)
197
+ writer.write_progress(0, 0.1, "Entry 1")
198
+ writer.write_progress(0, 0.2, "Entry 2")
199
+ writer.write_progress(0, 0.3, "Entry 3") # Triggers rotation
200
+ writer.write_progress(0, 0.4, "Entry 4")
201
+
202
+ # Read all entries
203
+ reader = ProgressFileReader(task_path)
204
+ entries = list(reader.read_all_entries())
205
+
206
+ assert len(entries) == 4
207
+ assert entries[0].desc == "Entry 1"
208
+ assert entries[1].desc == "Entry 2"
209
+ assert entries[2].desc == "Entry 3"
210
+ assert entries[3].desc == "Entry 4"
211
+
212
+ def test_read_latest_entries(self):
213
+ with tempfile.TemporaryDirectory() as tmpdir:
214
+ task_path = Path(tmpdir)
215
+
216
+ # Write many entries
217
+ writer = ProgressFileWriter(task_path, max_entries_per_file=3)
218
+ for i in range(10):
219
+ writer.write_progress(0, i / 10.0, f"Entry {i}")
220
+
221
+ # Read latest 5 entries
222
+ reader = ProgressFileReader(task_path)
223
+ latest = reader.read_latest_entries(5)
224
+
225
+ assert len(latest) == 5
226
+ # Should be entries 5-9 in chronological order
227
+ assert latest[0].desc == "Entry 5"
228
+ assert latest[4].desc == "Entry 9"
229
+
230
+ def test_get_current_progress(self):
231
+ with tempfile.TemporaryDirectory() as tmpdir:
232
+ task_path = Path(tmpdir)
233
+
234
+ # Write progress for multiple levels
235
+ writer = ProgressFileWriter(task_path)
236
+ writer.write_progress(0, 0.1, "Level 0 start")
237
+ writer.write_progress(1, 0.5, "Level 1 progress")
238
+ writer.write_progress(0, 0.5, "Level 0 update")
239
+ writer.write_progress(1, 1.0, "Level 1 complete")
240
+ writer.write_progress(0, 1.0, "Level 0 complete")
241
+
242
+ # Get current progress
243
+ reader = ProgressFileReader(task_path)
244
+ current = reader.get_current_progress()
245
+
246
+ assert len(current) == 2
247
+ assert current[0].progress == 1.0
248
+ assert current[0].desc == "Level 0 complete"
249
+ assert current[1].progress == 1.0
250
+ assert current[1].desc == "Level 1 complete"
251
+
252
+ def test_get_latest_file_via_symlink(self):
253
+ with tempfile.TemporaryDirectory() as tmpdir:
254
+ task_path = Path(tmpdir)
255
+
256
+ # Write some entries
257
+ writer = ProgressFileWriter(task_path, max_entries_per_file=2)
258
+ writer.write_progress(0, 0.1, "Entry 1")
259
+ writer.write_progress(0, 0.2, "Entry 2")
260
+ writer.write_progress(0, 0.3, "Entry 3") # Triggers rotation
261
+
262
+ # Get latest file
263
+ reader = ProgressFileReader(task_path)
264
+ latest_file = reader.get_latest_file()
265
+
266
+ expected_file = task_path / ".experimaestro" / "progress-0001.jsonl"
267
+ assert latest_file.name == expected_file.name
268
+
269
+ def test_no_progress_files(self):
270
+ with tempfile.TemporaryDirectory() as tmpdir:
271
+ task_path = Path(tmpdir)
272
+
273
+ reader = ProgressFileReader(task_path)
274
+
275
+ assert reader.get_progress_files() == []
276
+ assert reader.get_latest_file() is None
277
+ assert list(reader.read_all_entries()) == []
278
+ assert reader.read_latest_entries(10) == []
279
+ assert reader.get_current_progress() == {}
280
+
281
+
282
+ class TestFileBasedProgressReporter:
283
+ """Test FileBasedProgressReporter class"""
284
+
285
+ def test_set_progress_writes_to_file(self):
286
+ with tempfile.TemporaryDirectory() as tmpdir:
287
+ task_path = Path(tmpdir)
288
+
289
+ reporter = FileBasedProgressReporter(task_path)
290
+ reporter.set_progress(0.5, 0, "Test progress")
291
+
292
+ # Verify file was written
293
+ progress_file = task_path / ".experimaestro" / "progress-0000.jsonl"
294
+ assert progress_file.exists()
295
+
296
+ # Read and verify content
297
+ reader = ProgressFileReader(task_path)
298
+ entries = list(reader.read_all_entries())
299
+
300
+ assert len(entries) == 1
301
+ assert entries[0].level == 0
302
+ assert entries[0].progress == 0.5
303
+ assert entries[0].desc == "Test progress"
304
+
305
+ def test_set_progress_threshold(self):
306
+ with tempfile.TemporaryDirectory() as tmpdir:
307
+ task_path = Path(tmpdir)
308
+
309
+ reporter = FileBasedProgressReporter(task_path)
310
+
311
+ # First progress
312
+ reporter.set_progress(0.5, 0, "Test")
313
+
314
+ # Small change (should not write)
315
+ reporter.set_progress(0.505, 0, "Test")
316
+
317
+ # Larger change (should write)
318
+ reporter.set_progress(0.6, 0, "Test")
319
+
320
+ # Read entries
321
+ reader = ProgressFileReader(task_path)
322
+ entries = list(reader.read_all_entries())
323
+
324
+ # Should only have 2 entries (first and third)
325
+ assert len(entries) == 2
326
+ assert entries[0].progress == 0.5
327
+ assert entries[1].progress == 0.6
328
+
329
+ def test_set_progress_description_change(self):
330
+ with tempfile.TemporaryDirectory() as tmpdir:
331
+ task_path = Path(tmpdir)
332
+
333
+ reporter = FileBasedProgressReporter(task_path)
334
+
335
+ # Same progress, different description
336
+ reporter.set_progress(0.5, 0, "Description 1")
337
+ reporter.set_progress(0.5, 0, "Description 2")
338
+
339
+ # Read entries
340
+ reader = ProgressFileReader(task_path)
341
+ entries = list(reader.read_all_entries())
342
+
343
+ # Should have both entries due to description change
344
+ assert len(entries) == 2
345
+ assert entries[0].desc == "Description 1"
346
+ assert entries[1].desc == "Description 2"
347
+
348
+ def test_eoj_writes_marker(self):
349
+ with tempfile.TemporaryDirectory() as tmpdir:
350
+ task_path = Path(tmpdir)
351
+
352
+ reporter = FileBasedProgressReporter(task_path)
353
+ reporter.set_progress(1.0, 0, "Complete")
354
+ reporter.eoj()
355
+
356
+ # Read entries
357
+ reader = ProgressFileReader(task_path)
358
+ entries = list(reader.read_all_entries())
359
+
360
+ assert len(entries) == 2
361
+ assert entries[0].level == 0
362
+ assert entries[0].progress == 1.0
363
+ assert entries[1].level == -1 # EOJ marker
364
+ assert entries[1].progress == 1.0
365
+ assert entries[1].desc == "EOJ"
366
+
367
+ def test_multiple_levels(self):
368
+ with tempfile.TemporaryDirectory() as tmpdir:
369
+ task_path = Path(tmpdir)
370
+
371
+ reporter = FileBasedProgressReporter(task_path)
372
+
373
+ # Progress at different levels
374
+ reporter.set_progress(0.1, 0, "Main task")
375
+ reporter.set_progress(0.5, 1, "Subtask")
376
+ reporter.set_progress(0.3, 2, "Sub-subtask")
377
+ reporter.set_progress(0.5, 0, "Main task update")
378
+
379
+ # Read current progress
380
+ reader = ProgressFileReader(task_path)
381
+ current = reader.get_current_progress()
382
+
383
+ assert len(current) == 3
384
+ assert current[0].progress == 0.5
385
+ assert current[0].desc == "Main task update"
386
+ assert current[1].progress == 0.5
387
+ assert current[1].desc == "Subtask"
388
+ assert current[2].progress == 0.3
389
+ assert current[2].desc == "Sub-subtask"
390
+
391
+
392
+ class TestIntegrationWithNotifications:
393
+ """Test integration with existing notification system"""
394
+
395
+ @patch("experimaestro.taskglobals.Env.instance")
396
+ def test_progress_function_writes_to_file(self, mock_env):
397
+ """Test that the progress() function writes to file system"""
398
+ with tempfile.TemporaryDirectory() as tmpdir:
399
+ task_path = Path(tmpdir)
400
+
401
+ # Mock the task environment
402
+ mock_env.return_value.taskpath = task_path
403
+ mock_env.return_value.slave = False
404
+
405
+ # Import and call progress function
406
+ from experimaestro.notifications import progress
407
+
408
+ progress(0.5, level=0, desc="Test progress")
409
+
410
+ # Verify file was written
411
+ progress_file = task_path / ".experimaestro" / "progress-0000.jsonl"
412
+ assert progress_file.exists()
413
+
414
+ # Read and verify
415
+ reader = ProgressFileReader(task_path)
416
+ entries = list(reader.read_all_entries())
417
+
418
+ assert len(entries) == 1
419
+ assert entries[0].level == 0
420
+ assert entries[0].progress == 0.5
421
+ assert entries[0].desc == "Test progress"
422
+
423
+
424
+ if __name__ == "__main__":
425
+ pytest.main([__file__])