experimaestro 1.11.1__py3-none-any.whl → 2.0.0b4__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 (133) hide show
  1. experimaestro/__init__.py +10 -11
  2. experimaestro/annotations.py +167 -206
  3. experimaestro/cli/__init__.py +140 -16
  4. experimaestro/cli/filter.py +42 -74
  5. experimaestro/cli/jobs.py +157 -106
  6. experimaestro/cli/progress.py +269 -0
  7. experimaestro/cli/refactor.py +249 -0
  8. experimaestro/click.py +0 -1
  9. experimaestro/commandline.py +19 -3
  10. experimaestro/connectors/__init__.py +22 -3
  11. experimaestro/connectors/local.py +12 -0
  12. experimaestro/core/arguments.py +192 -37
  13. experimaestro/core/identifier.py +127 -12
  14. experimaestro/core/objects/__init__.py +6 -0
  15. experimaestro/core/objects/config.py +702 -285
  16. experimaestro/core/objects/config_walk.py +24 -6
  17. experimaestro/core/serialization.py +91 -34
  18. experimaestro/core/serializers.py +1 -8
  19. experimaestro/core/subparameters.py +164 -0
  20. experimaestro/core/types.py +198 -83
  21. experimaestro/exceptions.py +26 -0
  22. experimaestro/experiments/cli.py +107 -25
  23. experimaestro/generators.py +50 -9
  24. experimaestro/huggingface.py +3 -1
  25. experimaestro/launcherfinder/parser.py +29 -0
  26. experimaestro/launcherfinder/registry.py +3 -3
  27. experimaestro/launchers/__init__.py +26 -1
  28. experimaestro/launchers/direct.py +12 -0
  29. experimaestro/launchers/slurm/base.py +154 -2
  30. experimaestro/mkdocs/base.py +6 -8
  31. experimaestro/mkdocs/metaloader.py +0 -1
  32. experimaestro/mypy.py +452 -7
  33. experimaestro/notifications.py +75 -16
  34. experimaestro/progress.py +404 -0
  35. experimaestro/rpyc.py +0 -1
  36. experimaestro/run.py +19 -6
  37. experimaestro/scheduler/__init__.py +18 -1
  38. experimaestro/scheduler/base.py +504 -959
  39. experimaestro/scheduler/dependencies.py +43 -28
  40. experimaestro/scheduler/dynamic_outputs.py +259 -130
  41. experimaestro/scheduler/experiment.py +582 -0
  42. experimaestro/scheduler/interfaces.py +474 -0
  43. experimaestro/scheduler/jobs.py +485 -0
  44. experimaestro/scheduler/services.py +186 -12
  45. experimaestro/scheduler/signal_handler.py +32 -0
  46. experimaestro/scheduler/state.py +1 -1
  47. experimaestro/scheduler/state_db.py +388 -0
  48. experimaestro/scheduler/state_provider.py +2345 -0
  49. experimaestro/scheduler/state_sync.py +834 -0
  50. experimaestro/scheduler/workspace.py +52 -10
  51. experimaestro/scriptbuilder.py +7 -0
  52. experimaestro/server/__init__.py +153 -32
  53. experimaestro/server/data/index.css +0 -125
  54. experimaestro/server/data/index.css.map +1 -1
  55. experimaestro/server/data/index.js +194 -58
  56. experimaestro/server/data/index.js.map +1 -1
  57. experimaestro/settings.py +47 -6
  58. experimaestro/sphinx/__init__.py +3 -3
  59. experimaestro/taskglobals.py +20 -0
  60. experimaestro/tests/conftest.py +80 -0
  61. experimaestro/tests/core/test_generics.py +2 -2
  62. experimaestro/tests/identifier_stability.json +45 -0
  63. experimaestro/tests/launchers/bin/sacct +6 -2
  64. experimaestro/tests/launchers/bin/sbatch +4 -2
  65. experimaestro/tests/launchers/common.py +2 -2
  66. experimaestro/tests/launchers/test_slurm.py +80 -0
  67. experimaestro/tests/restart.py +1 -1
  68. experimaestro/tests/tasks/all.py +7 -0
  69. experimaestro/tests/tasks/test_dynamic.py +231 -0
  70. experimaestro/tests/test_checkers.py +2 -2
  71. experimaestro/tests/test_cli_jobs.py +615 -0
  72. experimaestro/tests/test_dependencies.py +11 -17
  73. experimaestro/tests/test_deprecated.py +630 -0
  74. experimaestro/tests/test_environment.py +200 -0
  75. experimaestro/tests/test_experiment.py +3 -3
  76. experimaestro/tests/test_file_progress.py +425 -0
  77. experimaestro/tests/test_file_progress_integration.py +477 -0
  78. experimaestro/tests/test_forward.py +3 -3
  79. experimaestro/tests/test_generators.py +93 -0
  80. experimaestro/tests/test_identifier.py +520 -169
  81. experimaestro/tests/test_identifier_stability.py +458 -0
  82. experimaestro/tests/test_instance.py +16 -21
  83. experimaestro/tests/test_multitoken.py +442 -0
  84. experimaestro/tests/test_mypy.py +433 -0
  85. experimaestro/tests/test_objects.py +314 -30
  86. experimaestro/tests/test_outputs.py +8 -8
  87. experimaestro/tests/test_param.py +22 -26
  88. experimaestro/tests/test_partial_paths.py +231 -0
  89. experimaestro/tests/test_progress.py +2 -50
  90. experimaestro/tests/test_resumable_task.py +480 -0
  91. experimaestro/tests/test_serializers.py +141 -60
  92. experimaestro/tests/test_state_db.py +434 -0
  93. experimaestro/tests/test_subparameters.py +160 -0
  94. experimaestro/tests/test_tags.py +151 -15
  95. experimaestro/tests/test_tasks.py +137 -160
  96. experimaestro/tests/test_token_locking.py +252 -0
  97. experimaestro/tests/test_tokens.py +25 -19
  98. experimaestro/tests/test_types.py +133 -11
  99. experimaestro/tests/test_validation.py +19 -19
  100. experimaestro/tests/test_workspace_triggers.py +158 -0
  101. experimaestro/tests/token_reschedule.py +5 -3
  102. experimaestro/tests/utils.py +2 -2
  103. experimaestro/tokens.py +154 -57
  104. experimaestro/tools/diff.py +8 -1
  105. experimaestro/tui/__init__.py +8 -0
  106. experimaestro/tui/app.py +2303 -0
  107. experimaestro/tui/app.tcss +353 -0
  108. experimaestro/tui/log_viewer.py +228 -0
  109. experimaestro/typingutils.py +11 -2
  110. experimaestro/utils/__init__.py +23 -0
  111. experimaestro/utils/environment.py +148 -0
  112. experimaestro/utils/git.py +129 -0
  113. experimaestro/utils/resources.py +1 -1
  114. experimaestro/version.py +34 -0
  115. {experimaestro-1.11.1.dist-info → experimaestro-2.0.0b4.dist-info}/METADATA +70 -39
  116. experimaestro-2.0.0b4.dist-info/RECORD +181 -0
  117. {experimaestro-1.11.1.dist-info → experimaestro-2.0.0b4.dist-info}/WHEEL +1 -1
  118. experimaestro-2.0.0b4.dist-info/entry_points.txt +16 -0
  119. experimaestro/compat.py +0 -6
  120. experimaestro/core/objects.pyi +0 -225
  121. experimaestro/server/data/0c35d18bf06992036b69.woff2 +0 -0
  122. experimaestro/server/data/219aa9140e099e6c72ed.woff2 +0 -0
  123. experimaestro/server/data/3a4004a46a653d4b2166.woff +0 -0
  124. experimaestro/server/data/3baa5b8f3469222b822d.woff +0 -0
  125. experimaestro/server/data/4d73cb90e394b34b7670.woff +0 -0
  126. experimaestro/server/data/4ef4218c522f1eb6b5b1.woff2 +0 -0
  127. experimaestro/server/data/5d681e2edae8c60630db.woff +0 -0
  128. experimaestro/server/data/6f420cf17cc0d7676fad.woff2 +0 -0
  129. experimaestro/server/data/c380809fd3677d7d6903.woff2 +0 -0
  130. experimaestro/server/data/f882956fd323fd322f31.woff +0 -0
  131. experimaestro-1.11.1.dist-info/RECORD +0 -158
  132. experimaestro-1.11.1.dist-info/entry_points.txt +0 -17
  133. {experimaestro-1.11.1.dist-info → experimaestro-2.0.0b4.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,200 @@
1
+ """Tests for environment capture utilities"""
2
+
3
+ import json
4
+ import pytest
5
+
6
+ from experimaestro.utils.git import get_git_info
7
+ from experimaestro.utils.environment import (
8
+ get_environment_info,
9
+ get_editable_packages_git_info,
10
+ save_environment_info,
11
+ load_environment_info,
12
+ )
13
+
14
+
15
+ class TestGetGitInfo:
16
+ """Tests for get_git_info function"""
17
+
18
+ def test_returns_dict_in_git_repo(self, tmp_path):
19
+ """Test that get_git_info returns a dict when in a git repo"""
20
+ # Use the current working directory which should be a git repo
21
+ git_info = get_git_info()
22
+
23
+ assert git_info is not None
24
+ assert isinstance(git_info, dict)
25
+ assert "commit" in git_info
26
+ assert "commit_short" in git_info
27
+ assert "branch" in git_info
28
+ assert "dirty" in git_info
29
+ assert "message" in git_info
30
+ assert "author" in git_info
31
+ assert "date" in git_info
32
+
33
+ def test_commit_format(self):
34
+ """Test that commit hashes have correct format"""
35
+ git_info = get_git_info()
36
+ if git_info is None:
37
+ pytest.skip("Not in a git repository")
38
+
39
+ # Full commit should be 40 hex characters
40
+ assert len(git_info["commit"]) == 40
41
+ assert all(c in "0123456789abcdef" for c in git_info["commit"])
42
+
43
+ # Short commit should be 7 characters
44
+ assert len(git_info["commit_short"]) == 7
45
+
46
+ def test_returns_none_for_non_git_dir(self, tmp_path):
47
+ """Test that get_git_info returns None for non-git directories"""
48
+ git_info = get_git_info(tmp_path)
49
+ assert git_info is None
50
+
51
+ def test_dirty_flag(self):
52
+ """Test that dirty flag is a boolean"""
53
+ git_info = get_git_info()
54
+ if git_info is None:
55
+ pytest.skip("Not in a git repository")
56
+
57
+ assert isinstance(git_info["dirty"], bool)
58
+
59
+
60
+ class TestGetEnvironmentInfo:
61
+ """Tests for get_environment_info function"""
62
+
63
+ def test_returns_dict_with_required_keys(self):
64
+ """Test that get_environment_info returns dict with required keys"""
65
+ env_info = get_environment_info()
66
+
67
+ assert isinstance(env_info, dict)
68
+ assert "python_version" in env_info
69
+ assert "packages" in env_info
70
+ assert "editable_packages" in env_info
71
+
72
+ def test_python_version_format(self):
73
+ """Test that python_version has correct format"""
74
+ env_info = get_environment_info()
75
+ version = env_info["python_version"]
76
+
77
+ # Should be in format X.Y.Z
78
+ parts = version.split(".")
79
+ assert len(parts) == 3
80
+ assert all(part.isdigit() for part in parts)
81
+
82
+ def test_packages_is_dict(self):
83
+ """Test that packages is a dict of name -> version"""
84
+ env_info = get_environment_info()
85
+ packages = env_info["packages"]
86
+
87
+ assert isinstance(packages, dict)
88
+ assert len(packages) > 0 # Should have at least some packages
89
+
90
+ # Check that all values are strings (versions)
91
+ for name, version in packages.items():
92
+ assert isinstance(name, str)
93
+ assert isinstance(version, str)
94
+
95
+ def test_experimaestro_is_editable(self):
96
+ """Test that experimaestro itself is detected as editable"""
97
+ env_info = get_environment_info()
98
+ editable = env_info["editable_packages"]
99
+
100
+ # When running tests, experimaestro should be installed in editable mode
101
+ assert "experimaestro" in editable
102
+ assert "version" in editable["experimaestro"]
103
+ assert "path" in editable["experimaestro"]
104
+ assert "git" in editable["experimaestro"]
105
+
106
+ def test_editable_package_has_git_info(self):
107
+ """Test that editable packages include git info"""
108
+ env_info = get_environment_info()
109
+ editable = env_info["editable_packages"]
110
+
111
+ # experimaestro should have git info since it's in a git repo
112
+ if "experimaestro" in editable:
113
+ git_info = editable["experimaestro"]["git"]
114
+ if git_info is not None: # May be None if not in git repo
115
+ assert "commit" in git_info
116
+ assert "dirty" in git_info
117
+
118
+
119
+ class TestGetEditablePackagesGitInfo:
120
+ """Tests for get_editable_packages_git_info function"""
121
+
122
+ def test_returns_dict(self):
123
+ """Test that function returns a dict"""
124
+ result = get_editable_packages_git_info()
125
+ assert isinstance(result, dict)
126
+
127
+ def test_contains_experimaestro(self):
128
+ """Test that experimaestro is in the result"""
129
+ result = get_editable_packages_git_info()
130
+ assert "experimaestro" in result
131
+
132
+
133
+ class TestSaveAndLoadEnvironmentInfo:
134
+ """Tests for save_environment_info and load_environment_info functions"""
135
+
136
+ def test_save_creates_file(self, tmp_path):
137
+ """Test that save_environment_info creates a JSON file"""
138
+ path = tmp_path / "environment.json"
139
+
140
+ result = save_environment_info(path)
141
+
142
+ assert path.exists()
143
+ assert isinstance(result, dict)
144
+
145
+ def test_save_writes_valid_json(self, tmp_path):
146
+ """Test that saved file contains valid JSON"""
147
+ path = tmp_path / "environment.json"
148
+
149
+ save_environment_info(path)
150
+
151
+ content = json.loads(path.read_text())
152
+ assert "python_version" in content
153
+ assert "packages" in content
154
+ assert "editable_packages" in content
155
+
156
+ def test_load_reads_saved_data(self, tmp_path):
157
+ """Test that load_environment_info reads back saved data"""
158
+ path = tmp_path / "environment.json"
159
+
160
+ saved = save_environment_info(path)
161
+ loaded = load_environment_info(path)
162
+
163
+ assert loaded == saved
164
+
165
+ def test_load_returns_none_for_missing_file(self, tmp_path):
166
+ """Test that load returns None for non-existent file"""
167
+ path = tmp_path / "nonexistent.json"
168
+
169
+ result = load_environment_info(path)
170
+
171
+ assert result is None
172
+
173
+ def test_load_returns_none_for_invalid_json(self, tmp_path):
174
+ """Test that load returns None for invalid JSON"""
175
+ path = tmp_path / "invalid.json"
176
+ path.write_text("not valid json{")
177
+
178
+ result = load_environment_info(path)
179
+
180
+ assert result is None
181
+
182
+
183
+ class TestExperimentEnvironmentSaving:
184
+ """Integration tests for environment saving in experiments"""
185
+
186
+ def test_experiment_saves_environment_info(self, xpmdirectory):
187
+ """Test that experiment saves environment.json on start"""
188
+ from experimaestro import experiment
189
+
190
+ # Just enter the experiment context, no need to run any tasks
191
+ with experiment(xpmdirectory, "test-env-save", port=-1) as xp:
192
+ pass # environment.json should be saved on __enter__
193
+
194
+ env_path = xp.workdir / "environment.json"
195
+ assert env_path.exists()
196
+
197
+ env_info = json.loads(env_path.read_text())
198
+ assert "python_version" in env_info
199
+ assert "packages" in env_info
200
+ assert "editable_packages" in env_info
@@ -38,8 +38,8 @@ def test_experiment_history():
38
38
  """Test retrieving experiment history"""
39
39
  with TemporaryDirectory() as workdir:
40
40
  with TemporaryExperiment("experiment", workdir=workdir):
41
- task_a = TaskA().submit()
42
- TaskB(task_a=task_a, x=tag(1)).submit()
41
+ task_a = TaskA.C().submit()
42
+ TaskB.C(task_a=task_a, x=tag(1)).submit()
43
43
 
44
44
  # Look at the experiment
45
45
  xp = get_experiment("experiment", workdir=workdir)
@@ -66,7 +66,7 @@ def test_experiment_events():
66
66
 
67
67
  flag = FlagHandler()
68
68
  with TemporaryExperiment("experiment"):
69
- task_a = TaskA()
69
+ task_a = TaskA.C()
70
70
  task_a.submit()
71
71
  task_a.on_completed(flag.set)
72
72
 
@@ -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__])