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.
- experimaestro/__init__.py +10 -11
- experimaestro/annotations.py +167 -206
- experimaestro/cli/__init__.py +140 -16
- experimaestro/cli/filter.py +42 -74
- experimaestro/cli/jobs.py +157 -106
- experimaestro/cli/progress.py +269 -0
- experimaestro/cli/refactor.py +249 -0
- experimaestro/click.py +0 -1
- experimaestro/commandline.py +19 -3
- experimaestro/connectors/__init__.py +22 -3
- experimaestro/connectors/local.py +12 -0
- experimaestro/core/arguments.py +192 -37
- experimaestro/core/identifier.py +127 -12
- experimaestro/core/objects/__init__.py +6 -0
- experimaestro/core/objects/config.py +702 -285
- experimaestro/core/objects/config_walk.py +24 -6
- experimaestro/core/serialization.py +91 -34
- experimaestro/core/serializers.py +1 -8
- experimaestro/core/subparameters.py +164 -0
- experimaestro/core/types.py +198 -83
- experimaestro/exceptions.py +26 -0
- experimaestro/experiments/cli.py +107 -25
- experimaestro/generators.py +50 -9
- experimaestro/huggingface.py +3 -1
- experimaestro/launcherfinder/parser.py +29 -0
- experimaestro/launcherfinder/registry.py +3 -3
- experimaestro/launchers/__init__.py +26 -1
- experimaestro/launchers/direct.py +12 -0
- experimaestro/launchers/slurm/base.py +154 -2
- experimaestro/mkdocs/base.py +6 -8
- experimaestro/mkdocs/metaloader.py +0 -1
- experimaestro/mypy.py +452 -7
- experimaestro/notifications.py +75 -16
- experimaestro/progress.py +404 -0
- experimaestro/rpyc.py +0 -1
- experimaestro/run.py +19 -6
- experimaestro/scheduler/__init__.py +18 -1
- experimaestro/scheduler/base.py +504 -959
- experimaestro/scheduler/dependencies.py +43 -28
- experimaestro/scheduler/dynamic_outputs.py +259 -130
- experimaestro/scheduler/experiment.py +582 -0
- experimaestro/scheduler/interfaces.py +474 -0
- experimaestro/scheduler/jobs.py +485 -0
- experimaestro/scheduler/services.py +186 -12
- experimaestro/scheduler/signal_handler.py +32 -0
- experimaestro/scheduler/state.py +1 -1
- experimaestro/scheduler/state_db.py +388 -0
- experimaestro/scheduler/state_provider.py +2345 -0
- experimaestro/scheduler/state_sync.py +834 -0
- experimaestro/scheduler/workspace.py +52 -10
- experimaestro/scriptbuilder.py +7 -0
- experimaestro/server/__init__.py +153 -32
- experimaestro/server/data/index.css +0 -125
- experimaestro/server/data/index.css.map +1 -1
- experimaestro/server/data/index.js +194 -58
- experimaestro/server/data/index.js.map +1 -1
- experimaestro/settings.py +47 -6
- experimaestro/sphinx/__init__.py +3 -3
- experimaestro/taskglobals.py +20 -0
- experimaestro/tests/conftest.py +80 -0
- experimaestro/tests/core/test_generics.py +2 -2
- experimaestro/tests/identifier_stability.json +45 -0
- experimaestro/tests/launchers/bin/sacct +6 -2
- experimaestro/tests/launchers/bin/sbatch +4 -2
- experimaestro/tests/launchers/common.py +2 -2
- experimaestro/tests/launchers/test_slurm.py +80 -0
- experimaestro/tests/restart.py +1 -1
- experimaestro/tests/tasks/all.py +7 -0
- experimaestro/tests/tasks/test_dynamic.py +231 -0
- experimaestro/tests/test_checkers.py +2 -2
- experimaestro/tests/test_cli_jobs.py +615 -0
- experimaestro/tests/test_dependencies.py +11 -17
- experimaestro/tests/test_deprecated.py +630 -0
- experimaestro/tests/test_environment.py +200 -0
- experimaestro/tests/test_experiment.py +3 -3
- experimaestro/tests/test_file_progress.py +425 -0
- experimaestro/tests/test_file_progress_integration.py +477 -0
- experimaestro/tests/test_forward.py +3 -3
- experimaestro/tests/test_generators.py +93 -0
- experimaestro/tests/test_identifier.py +520 -169
- experimaestro/tests/test_identifier_stability.py +458 -0
- experimaestro/tests/test_instance.py +16 -21
- experimaestro/tests/test_multitoken.py +442 -0
- experimaestro/tests/test_mypy.py +433 -0
- experimaestro/tests/test_objects.py +314 -30
- experimaestro/tests/test_outputs.py +8 -8
- experimaestro/tests/test_param.py +22 -26
- experimaestro/tests/test_partial_paths.py +231 -0
- experimaestro/tests/test_progress.py +2 -50
- experimaestro/tests/test_resumable_task.py +480 -0
- experimaestro/tests/test_serializers.py +141 -60
- experimaestro/tests/test_state_db.py +434 -0
- experimaestro/tests/test_subparameters.py +160 -0
- experimaestro/tests/test_tags.py +151 -15
- experimaestro/tests/test_tasks.py +137 -160
- experimaestro/tests/test_token_locking.py +252 -0
- experimaestro/tests/test_tokens.py +25 -19
- experimaestro/tests/test_types.py +133 -11
- experimaestro/tests/test_validation.py +19 -19
- experimaestro/tests/test_workspace_triggers.py +158 -0
- experimaestro/tests/token_reschedule.py +5 -3
- experimaestro/tests/utils.py +2 -2
- experimaestro/tokens.py +154 -57
- experimaestro/tools/diff.py +8 -1
- experimaestro/tui/__init__.py +8 -0
- experimaestro/tui/app.py +2303 -0
- experimaestro/tui/app.tcss +353 -0
- experimaestro/tui/log_viewer.py +228 -0
- experimaestro/typingutils.py +11 -2
- experimaestro/utils/__init__.py +23 -0
- experimaestro/utils/environment.py +148 -0
- experimaestro/utils/git.py +129 -0
- experimaestro/utils/resources.py +1 -1
- experimaestro/version.py +34 -0
- {experimaestro-1.11.1.dist-info → experimaestro-2.0.0b4.dist-info}/METADATA +70 -39
- experimaestro-2.0.0b4.dist-info/RECORD +181 -0
- {experimaestro-1.11.1.dist-info → experimaestro-2.0.0b4.dist-info}/WHEEL +1 -1
- experimaestro-2.0.0b4.dist-info/entry_points.txt +16 -0
- experimaestro/compat.py +0 -6
- experimaestro/core/objects.pyi +0 -225
- experimaestro/server/data/0c35d18bf06992036b69.woff2 +0 -0
- experimaestro/server/data/219aa9140e099e6c72ed.woff2 +0 -0
- experimaestro/server/data/3a4004a46a653d4b2166.woff +0 -0
- experimaestro/server/data/3baa5b8f3469222b822d.woff +0 -0
- experimaestro/server/data/4d73cb90e394b34b7670.woff +0 -0
- experimaestro/server/data/4ef4218c522f1eb6b5b1.woff2 +0 -0
- experimaestro/server/data/5d681e2edae8c60630db.woff +0 -0
- experimaestro/server/data/6f420cf17cc0d7676fad.woff2 +0 -0
- experimaestro/server/data/c380809fd3677d7d6903.woff2 +0 -0
- experimaestro/server/data/f882956fd323fd322f31.woff +0 -0
- experimaestro-1.11.1.dist-info/RECORD +0 -158
- experimaestro-1.11.1.dist-info/entry_points.txt +0 -17
- {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__])
|