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.
- experimaestro/__init__.py +12 -5
- experimaestro/cli/__init__.py +239 -126
- experimaestro/cli/filter.py +48 -23
- experimaestro/cli/jobs.py +253 -71
- experimaestro/cli/refactor.py +1 -2
- experimaestro/commandline.py +7 -4
- experimaestro/connectors/__init__.py +9 -1
- experimaestro/connectors/local.py +43 -3
- experimaestro/core/arguments.py +18 -18
- experimaestro/core/identifier.py +11 -11
- experimaestro/core/objects/config.py +96 -39
- experimaestro/core/objects/config_walk.py +3 -3
- experimaestro/core/{subparameters.py → partial.py} +16 -16
- experimaestro/core/partial_lock.py +394 -0
- experimaestro/core/types.py +12 -15
- experimaestro/dynamic.py +290 -0
- experimaestro/experiments/__init__.py +6 -2
- experimaestro/experiments/cli.py +217 -50
- experimaestro/experiments/configuration.py +24 -0
- experimaestro/generators.py +5 -5
- experimaestro/ipc.py +118 -1
- experimaestro/launcherfinder/__init__.py +2 -2
- experimaestro/launcherfinder/registry.py +6 -7
- experimaestro/launcherfinder/specs.py +2 -9
- experimaestro/launchers/slurm/__init__.py +2 -2
- experimaestro/launchers/slurm/base.py +62 -0
- experimaestro/locking.py +957 -1
- experimaestro/notifications.py +89 -201
- experimaestro/progress.py +63 -366
- experimaestro/rpyc.py +0 -2
- experimaestro/run.py +29 -2
- experimaestro/scheduler/__init__.py +8 -1
- experimaestro/scheduler/base.py +629 -53
- experimaestro/scheduler/dependencies.py +20 -16
- experimaestro/scheduler/experiment.py +732 -167
- experimaestro/scheduler/interfaces.py +316 -101
- experimaestro/scheduler/jobs.py +58 -20
- experimaestro/scheduler/remote/adaptive_sync.py +265 -0
- experimaestro/scheduler/remote/client.py +171 -117
- experimaestro/scheduler/remote/protocol.py +8 -193
- experimaestro/scheduler/remote/server.py +95 -71
- experimaestro/scheduler/services.py +53 -28
- experimaestro/scheduler/state_provider.py +663 -2430
- experimaestro/scheduler/state_status.py +1247 -0
- experimaestro/scheduler/transient.py +31 -0
- experimaestro/scheduler/workspace.py +1 -1
- experimaestro/scheduler/workspace_state_provider.py +1273 -0
- experimaestro/scriptbuilder.py +4 -4
- experimaestro/settings.py +36 -0
- experimaestro/tests/conftest.py +33 -5
- experimaestro/tests/connectors/bin/executable.py +1 -1
- experimaestro/tests/fixtures/pre_experiment/experiment_check_env.py +16 -0
- experimaestro/tests/fixtures/pre_experiment/experiment_check_mock.py +14 -0
- experimaestro/tests/fixtures/pre_experiment/experiment_simple.py +12 -0
- experimaestro/tests/fixtures/pre_experiment/pre_setup_env.py +5 -0
- experimaestro/tests/fixtures/pre_experiment/pre_setup_error.py +3 -0
- experimaestro/tests/fixtures/pre_experiment/pre_setup_mock.py +8 -0
- experimaestro/tests/launchers/bin/test.py +1 -0
- experimaestro/tests/launchers/test_slurm.py +9 -9
- experimaestro/tests/partial_reschedule.py +46 -0
- experimaestro/tests/restart.py +3 -3
- experimaestro/tests/restart_main.py +1 -0
- experimaestro/tests/scripts/notifyandwait.py +1 -0
- experimaestro/tests/task_partial.py +38 -0
- experimaestro/tests/task_tokens.py +2 -2
- experimaestro/tests/tasks/test_dynamic.py +6 -6
- experimaestro/tests/test_dependencies.py +3 -3
- experimaestro/tests/test_deprecated.py +15 -15
- experimaestro/tests/test_dynamic_locking.py +317 -0
- experimaestro/tests/test_environment.py +24 -14
- experimaestro/tests/test_experiment.py +171 -36
- experimaestro/tests/test_identifier.py +25 -25
- experimaestro/tests/test_identifier_stability.py +3 -5
- experimaestro/tests/test_multitoken.py +2 -4
- experimaestro/tests/{test_subparameters.py → test_partial.py} +25 -25
- experimaestro/tests/test_partial_paths.py +81 -138
- experimaestro/tests/test_pre_experiment.py +219 -0
- experimaestro/tests/test_progress.py +2 -8
- experimaestro/tests/test_remote_state.py +560 -99
- experimaestro/tests/test_stray_jobs.py +261 -0
- experimaestro/tests/test_tasks.py +1 -2
- experimaestro/tests/test_token_locking.py +52 -67
- experimaestro/tests/test_tokens.py +5 -6
- experimaestro/tests/test_transient.py +225 -0
- experimaestro/tests/test_workspace_state_provider.py +768 -0
- experimaestro/tests/token_reschedule.py +1 -3
- experimaestro/tests/utils.py +2 -7
- experimaestro/tokens.py +227 -372
- experimaestro/tools/diff.py +1 -0
- experimaestro/tools/documentation.py +4 -5
- experimaestro/tools/jobs.py +1 -2
- experimaestro/tui/app.py +438 -1966
- experimaestro/tui/app.tcss +162 -0
- experimaestro/tui/dialogs.py +172 -0
- experimaestro/tui/log_viewer.py +253 -3
- experimaestro/tui/messages.py +137 -0
- experimaestro/tui/utils.py +54 -0
- experimaestro/tui/widgets/__init__.py +23 -0
- experimaestro/tui/widgets/experiments.py +468 -0
- experimaestro/tui/widgets/global_services.py +238 -0
- experimaestro/tui/widgets/jobs.py +972 -0
- experimaestro/tui/widgets/log.py +156 -0
- experimaestro/tui/widgets/orphans.py +363 -0
- experimaestro/tui/widgets/runs.py +185 -0
- experimaestro/tui/widgets/services.py +314 -0
- experimaestro/tui/widgets/stray_jobs.py +528 -0
- experimaestro/utils/__init__.py +1 -1
- experimaestro/utils/environment.py +105 -22
- experimaestro/utils/fswatcher.py +124 -0
- experimaestro/utils/jobs.py +1 -2
- experimaestro/utils/jupyter.py +1 -2
- experimaestro/utils/logging.py +72 -0
- experimaestro/version.py +2 -2
- experimaestro/webui/__init__.py +9 -0
- experimaestro/webui/app.py +117 -0
- experimaestro/{server → webui}/data/index.css +66 -11
- experimaestro/webui/data/index.css.map +1 -0
- experimaestro/{server → webui}/data/index.js +82763 -87217
- experimaestro/webui/data/index.js.map +1 -0
- experimaestro/webui/routes/__init__.py +5 -0
- experimaestro/webui/routes/auth.py +53 -0
- experimaestro/webui/routes/proxy.py +117 -0
- experimaestro/webui/server.py +200 -0
- experimaestro/webui/state_bridge.py +152 -0
- experimaestro/webui/websocket.py +413 -0
- {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/METADATA +5 -6
- experimaestro-2.0.0b17.dist-info/RECORD +219 -0
- experimaestro/cli/progress.py +0 -269
- experimaestro/scheduler/state.py +0 -75
- experimaestro/scheduler/state_db.py +0 -437
- experimaestro/scheduler/state_sync.py +0 -891
- experimaestro/server/__init__.py +0 -467
- experimaestro/server/data/index.css.map +0 -1
- experimaestro/server/data/index.js.map +0 -1
- experimaestro/tests/test_cli_jobs.py +0 -615
- experimaestro/tests/test_file_progress.py +0 -425
- experimaestro/tests/test_file_progress_integration.py +0 -477
- experimaestro/tests/test_state_db.py +0 -434
- experimaestro-2.0.0b8.dist-info/RECORD +0 -187
- /experimaestro/{server → webui}/data/1815e00441357e01619e.ttf +0 -0
- /experimaestro/{server → webui}/data/2463b90d9a316e4e5294.woff2 +0 -0
- /experimaestro/{server → webui}/data/2582b0e4bcf85eceead0.ttf +0 -0
- /experimaestro/{server → webui}/data/89999bdf5d835c012025.woff2 +0 -0
- /experimaestro/{server → webui}/data/914997e1bdfc990d0897.ttf +0 -0
- /experimaestro/{server → webui}/data/c210719e60948b211a12.woff2 +0 -0
- /experimaestro/{server → webui}/data/favicon.ico +0 -0
- /experimaestro/{server → webui}/data/index.html +0 -0
- /experimaestro/{server → webui}/data/login.html +0 -0
- /experimaestro/{server → webui}/data/manifest.json +0 -0
- {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/WHEEL +0 -0
- {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/entry_points.txt +0 -0
- {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/licenses/LICENSE +0 -0
|
@@ -4,12 +4,16 @@ This module provides functions to capture the full Python environment state
|
|
|
4
4
|
when experiments are run, including:
|
|
5
5
|
- Git information for editable (development) packages
|
|
6
6
|
- Version information for all installed Python packages
|
|
7
|
+
- Run information (hostname, start time) for experiment runs
|
|
7
8
|
"""
|
|
8
9
|
|
|
10
|
+
import json
|
|
9
11
|
import sys
|
|
10
12
|
import logging
|
|
13
|
+
from dataclasses import dataclass, field
|
|
11
14
|
from pathlib import Path
|
|
12
|
-
from typing import Optional
|
|
15
|
+
from typing import Any, Dict, Optional
|
|
16
|
+
|
|
13
17
|
from importlib.metadata import distributions
|
|
14
18
|
|
|
15
19
|
from experimaestro.utils.git import get_git_info
|
|
@@ -17,6 +21,94 @@ from experimaestro.utils.git import get_git_info
|
|
|
17
21
|
logger = logging.getLogger("xpm.environment")
|
|
18
22
|
|
|
19
23
|
|
|
24
|
+
@dataclass
|
|
25
|
+
class ExperimentRunInfo:
|
|
26
|
+
"""Information about a single experiment run"""
|
|
27
|
+
|
|
28
|
+
hostname: Optional[str] = None
|
|
29
|
+
started_at: Optional[str] = None
|
|
30
|
+
ended_at: Optional[str] = None
|
|
31
|
+
status: Optional[str] = None # 'completed' or 'failed'
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
34
|
+
"""Convert to dictionary for JSON serialization"""
|
|
35
|
+
return {
|
|
36
|
+
"hostname": self.hostname,
|
|
37
|
+
"started_at": self.started_at,
|
|
38
|
+
"ended_at": self.ended_at,
|
|
39
|
+
"status": self.status,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_dict(cls, data: Dict[str, Any]) -> "ExperimentRunInfo":
|
|
44
|
+
"""Create from dictionary"""
|
|
45
|
+
return cls(
|
|
46
|
+
hostname=data.get("hostname"),
|
|
47
|
+
started_at=data.get("started_at"),
|
|
48
|
+
ended_at=data.get("ended_at"),
|
|
49
|
+
status=data.get("status"),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class ExperimentEnvironment:
|
|
55
|
+
"""Experiment environment stored in environment.json
|
|
56
|
+
|
|
57
|
+
This combines Python environment info with experiment run metadata.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
python_version: Optional[str] = None
|
|
61
|
+
packages: Dict[str, str] = field(default_factory=dict)
|
|
62
|
+
editable_packages: Dict[str, Any] = field(default_factory=dict)
|
|
63
|
+
run: Optional[ExperimentRunInfo] = None
|
|
64
|
+
projects: list[Dict[str, Any]] = field(
|
|
65
|
+
default_factory=list
|
|
66
|
+
) # Git info for projects
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
69
|
+
"""Convert to dictionary for JSON serialization"""
|
|
70
|
+
result: Dict[str, Any] = {
|
|
71
|
+
"python_version": self.python_version,
|
|
72
|
+
"packages": self.packages,
|
|
73
|
+
"editable_packages": self.editable_packages,
|
|
74
|
+
}
|
|
75
|
+
if self.run is not None:
|
|
76
|
+
result["run"] = self.run.to_dict()
|
|
77
|
+
if self.projects:
|
|
78
|
+
result["projects"] = self.projects
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_dict(cls, data: Dict[str, Any]) -> "ExperimentEnvironment":
|
|
83
|
+
"""Create from dictionary"""
|
|
84
|
+
run_data = data.get("run")
|
|
85
|
+
run = ExperimentRunInfo.from_dict(run_data) if run_data else None
|
|
86
|
+
return cls(
|
|
87
|
+
python_version=data.get("python_version"),
|
|
88
|
+
packages=data.get("packages", {}),
|
|
89
|
+
editable_packages=data.get("editable_packages", {}),
|
|
90
|
+
run=run,
|
|
91
|
+
projects=data.get("projects", []),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def load(cls, path: Path) -> "ExperimentEnvironment":
|
|
96
|
+
"""Load from a JSON file"""
|
|
97
|
+
if not path.exists():
|
|
98
|
+
return cls()
|
|
99
|
+
try:
|
|
100
|
+
with path.open("r") as f:
|
|
101
|
+
data = json.load(f)
|
|
102
|
+
return cls.from_dict(data)
|
|
103
|
+
except (json.JSONDecodeError, Exception) as e:
|
|
104
|
+
logger.warning("Failed to read environment.json: %s", e)
|
|
105
|
+
return cls()
|
|
106
|
+
|
|
107
|
+
def save(self, path: Path) -> None:
|
|
108
|
+
"""Save to a JSON file"""
|
|
109
|
+
path.write_text(json.dumps(self.to_dict(), indent=2))
|
|
110
|
+
|
|
111
|
+
|
|
20
112
|
def get_environment_info() -> dict:
|
|
21
113
|
"""Capture complete environment information for reproducibility
|
|
22
114
|
|
|
@@ -110,39 +202,30 @@ def get_editable_packages_git_info() -> dict:
|
|
|
110
202
|
return editable_packages
|
|
111
203
|
|
|
112
204
|
|
|
113
|
-
def
|
|
114
|
-
"""
|
|
115
|
-
|
|
116
|
-
Args:
|
|
117
|
-
path: Path to save the environment info JSON file
|
|
205
|
+
def get_current_environment() -> ExperimentEnvironment:
|
|
206
|
+
"""Get current environment as an ExperimentEnvironment object
|
|
118
207
|
|
|
119
208
|
Returns:
|
|
120
|
-
|
|
209
|
+
ExperimentEnvironment with current Python version, packages, etc.
|
|
121
210
|
"""
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
211
|
+
current_info = get_environment_info()
|
|
212
|
+
return ExperimentEnvironment(
|
|
213
|
+
python_version=current_info["python_version"],
|
|
214
|
+
packages=current_info["packages"],
|
|
215
|
+
editable_packages=current_info["editable_packages"],
|
|
216
|
+
)
|
|
128
217
|
|
|
129
218
|
|
|
130
|
-
def load_environment_info(path: Path) -> Optional[
|
|
219
|
+
def load_environment_info(path: Path) -> Optional[ExperimentEnvironment]:
|
|
131
220
|
"""Load environment information from a JSON file
|
|
132
221
|
|
|
133
222
|
Args:
|
|
134
223
|
path: Path to the environment info JSON file
|
|
135
224
|
|
|
136
225
|
Returns:
|
|
137
|
-
|
|
226
|
+
ExperimentEnvironment if file exists and is valid, None otherwise
|
|
138
227
|
"""
|
|
139
|
-
import json
|
|
140
|
-
|
|
141
228
|
if not path.exists():
|
|
142
229
|
return None
|
|
143
230
|
|
|
144
|
-
|
|
145
|
-
return json.loads(path.read_text())
|
|
146
|
-
except (json.JSONDecodeError, OSError) as e:
|
|
147
|
-
logger.warning("Error loading environment info from %s: %s", path, e)
|
|
148
|
-
return None
|
|
231
|
+
return ExperimentEnvironment.load(path)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""File system watcher utilities
|
|
2
|
+
|
|
3
|
+
Workarounds for platform-specific file watching limitations.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("xpm.fswatcher")
|
|
13
|
+
|
|
14
|
+
# Marker file for macOS FSEvents workaround
|
|
15
|
+
# See https://github.com/experimaestro/experimaestro-python/issues/154
|
|
16
|
+
# FSEvents doesn't reliably detect SQLite WAL file changes
|
|
17
|
+
DB_CHANGE_MARKER = ".db_changed"
|
|
18
|
+
DB_CHANGE_MARKER_DEBOUNCE_SECONDS = 1.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FSEventsMarkerWorkaround:
|
|
22
|
+
"""Workaround for FSEvents not detecting SQLite WAL file changes on macOS
|
|
23
|
+
|
|
24
|
+
On macOS, FSEvents doesn't reliably detect SQLite WAL file modifications.
|
|
25
|
+
This class schedules a marker file touch after a delay. If the file system
|
|
26
|
+
detects the database change before the delay (via cancel()), the touch is
|
|
27
|
+
skipped.
|
|
28
|
+
|
|
29
|
+
Multiple rapid DB writes reset the timer, so touch happens only once after
|
|
30
|
+
DB_CHANGE_MARKER_DEBOUNCE_SECONDS of inactivity.
|
|
31
|
+
|
|
32
|
+
Usage:
|
|
33
|
+
workaround = FSEventsMarkerWorkaround(db_dir)
|
|
34
|
+
|
|
35
|
+
# After each DB write:
|
|
36
|
+
workaround.schedule_touch()
|
|
37
|
+
|
|
38
|
+
# When FSEvents detects a change:
|
|
39
|
+
workaround.cancel()
|
|
40
|
+
|
|
41
|
+
# On cleanup:
|
|
42
|
+
workaround.stop()
|
|
43
|
+
|
|
44
|
+
See https://github.com/experimaestro/experimaestro-python/issues/154
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
db_dir: Path,
|
|
50
|
+
debounce_seconds: float = DB_CHANGE_MARKER_DEBOUNCE_SECONDS,
|
|
51
|
+
):
|
|
52
|
+
"""Initialize the workaround
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
db_dir: Directory containing the database files
|
|
56
|
+
debounce_seconds: Delay before touching the marker file
|
|
57
|
+
"""
|
|
58
|
+
self._db_dir = db_dir
|
|
59
|
+
self._debounce_seconds = debounce_seconds
|
|
60
|
+
self._timer: Optional[threading.Timer] = None
|
|
61
|
+
self._lock = threading.Lock()
|
|
62
|
+
self._enabled = sys.platform == "darwin"
|
|
63
|
+
|
|
64
|
+
def schedule_touch(self) -> None:
|
|
65
|
+
"""Schedule a marker file touch after the delay
|
|
66
|
+
|
|
67
|
+
First call schedules a touch in debounce_seconds. Subsequent calls
|
|
68
|
+
within that window are ignored. After the touch happens, the next
|
|
69
|
+
call can schedule another touch.
|
|
70
|
+
|
|
71
|
+
This provides rate limiting: touch happens at most once per
|
|
72
|
+
debounce_seconds interval.
|
|
73
|
+
"""
|
|
74
|
+
if not self._enabled:
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
with self._lock:
|
|
78
|
+
# If timer already running, ignore this event
|
|
79
|
+
if self._timer is not None:
|
|
80
|
+
logger.debug("Marker file touch already scheduled, ignoring")
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
# Schedule new touch
|
|
84
|
+
self._timer = threading.Timer(
|
|
85
|
+
self._debounce_seconds,
|
|
86
|
+
self._do_touch,
|
|
87
|
+
)
|
|
88
|
+
self._timer.daemon = True
|
|
89
|
+
self._timer.start()
|
|
90
|
+
logger.debug("Scheduled marker file touch in %.1fs", self._debounce_seconds)
|
|
91
|
+
|
|
92
|
+
def cancel(self) -> None:
|
|
93
|
+
"""Cancel any pending marker file touch
|
|
94
|
+
|
|
95
|
+
Call this when FSEvents successfully detected a database change,
|
|
96
|
+
meaning the workaround is not needed for this change.
|
|
97
|
+
"""
|
|
98
|
+
if not self._enabled:
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
with self._lock:
|
|
102
|
+
if self._timer is not None:
|
|
103
|
+
self._timer.cancel()
|
|
104
|
+
self._timer = None
|
|
105
|
+
logger.debug("Cancelled pending marker file touch")
|
|
106
|
+
|
|
107
|
+
def stop(self) -> None:
|
|
108
|
+
"""Stop and cleanup (call on shutdown)"""
|
|
109
|
+
self.cancel()
|
|
110
|
+
|
|
111
|
+
def _do_touch(self) -> None:
|
|
112
|
+
"""Actually touch the marker file (called by timer)"""
|
|
113
|
+
with self._lock:
|
|
114
|
+
self._timer = None
|
|
115
|
+
|
|
116
|
+
if not self._enabled:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
marker_path = self._db_dir / DB_CHANGE_MARKER
|
|
121
|
+
marker_path.touch()
|
|
122
|
+
logger.debug("Touched marker file %s for FSEvents workaround", marker_path)
|
|
123
|
+
except Exception as e:
|
|
124
|
+
logger.warning("Failed to touch marker file: %s", e)
|
experimaestro/utils/jobs.py
CHANGED
|
@@ -60,8 +60,7 @@ def jobmonitor(*outputs: Config):
|
|
|
60
60
|
reporter.update(100 - progress)
|
|
61
61
|
else:
|
|
62
62
|
raise RuntimeError(
|
|
63
|
-
f"Job did not complete successfully ({job.state.name})."
|
|
64
|
-
f"Check the error log {job.stderr}"
|
|
63
|
+
f"Job did not complete successfully ({job.state.name}).Check the error log {job.stderr}"
|
|
65
64
|
)
|
|
66
65
|
|
|
67
66
|
finally:
|
experimaestro/utils/jupyter.py
CHANGED
|
@@ -36,8 +36,7 @@ class serverwidget:
|
|
|
36
36
|
if experiment.CURRENT:
|
|
37
37
|
self.button.description = "Stop experimaestro server"
|
|
38
38
|
print( # noqa: T201
|
|
39
|
-
"Server started : "
|
|
40
|
-
f"http://localhost:{self.port}/auth?xpm-token={serverwidget.TOKEN}"
|
|
39
|
+
f"Server started : http://localhost:{self.port}/auth?xpm-token={serverwidget.TOKEN}"
|
|
41
40
|
)
|
|
42
41
|
else:
|
|
43
42
|
self.button.description = "Start experimaestro server"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Logging utilities with colored output support"""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from termcolor import colored
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ColoredFormatter(logging.Formatter):
|
|
11
|
+
"""Logging formatter with colors for terminal output"""
|
|
12
|
+
|
|
13
|
+
COLORS = {
|
|
14
|
+
logging.DEBUG: "dark_grey",
|
|
15
|
+
logging.INFO: "green",
|
|
16
|
+
logging.WARNING: "yellow",
|
|
17
|
+
logging.ERROR: "red",
|
|
18
|
+
logging.CRITICAL: "red",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
LEVEL_NAMES = {
|
|
22
|
+
logging.DEBUG: "DEBUG",
|
|
23
|
+
logging.INFO: "INFO",
|
|
24
|
+
logging.WARNING: "WARN",
|
|
25
|
+
logging.ERROR: "ERROR",
|
|
26
|
+
logging.CRITICAL: "CRIT",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
def __init__(self, use_color: bool = True):
|
|
30
|
+
super().__init__()
|
|
31
|
+
self.use_color = use_color
|
|
32
|
+
|
|
33
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
34
|
+
# ISO format timestamp
|
|
35
|
+
timestamp = datetime.datetime.fromtimestamp(record.created).isoformat(
|
|
36
|
+
timespec="seconds"
|
|
37
|
+
)
|
|
38
|
+
level_name = self.LEVEL_NAMES.get(record.levelno, record.levelname[:5])
|
|
39
|
+
message = record.getMessage()
|
|
40
|
+
|
|
41
|
+
if self.use_color:
|
|
42
|
+
color = self.COLORS.get(record.levelno, "white")
|
|
43
|
+
level_str = colored(f"{level_name:5}", color, attrs=["bold"])
|
|
44
|
+
name_str = colored(record.name, "cyan")
|
|
45
|
+
return f"{timestamp} {level_str} {name_str}: {message}"
|
|
46
|
+
else:
|
|
47
|
+
return f"{timestamp} {level_name:5} {record.name}: {message}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def setup_logging(debug: bool = False, force_color: bool = False):
|
|
51
|
+
"""Set up logging with optional colors for terminal output
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
debug: Enable debug level logging
|
|
55
|
+
force_color: Force colored output even if not a TTY
|
|
56
|
+
"""
|
|
57
|
+
root_logger = logging.getLogger()
|
|
58
|
+
root_logger.setLevel(logging.DEBUG if debug else logging.INFO)
|
|
59
|
+
|
|
60
|
+
# Remove existing handlers
|
|
61
|
+
for handler in root_logger.handlers[:]:
|
|
62
|
+
root_logger.removeHandler(handler)
|
|
63
|
+
|
|
64
|
+
# Check if stderr is a TTY (terminal) for colored output
|
|
65
|
+
use_color = force_color or sys.stderr.isatty()
|
|
66
|
+
|
|
67
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
68
|
+
handler.setFormatter(ColoredFormatter(use_color=use_color))
|
|
69
|
+
root_logger.addHandler(handler)
|
|
70
|
+
|
|
71
|
+
# Set specific loggers to INFO to reduce noise
|
|
72
|
+
logging.getLogger("xpm.hash").setLevel(logging.INFO)
|
experimaestro/version.py
CHANGED
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '2.0.
|
|
32
|
-
__version_tuple__ = version_tuple = (2, 0, 0, '
|
|
31
|
+
__version__ = version = '2.0.0b17'
|
|
32
|
+
__version_tuple__ = version_tuple = (2, 0, 0, 'b17')
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""WebUI module for experimaestro
|
|
2
|
+
|
|
3
|
+
FastAPI-based web server with native WebSocket support for monitoring experiments.
|
|
4
|
+
Aligned with TUI architecture using StateProvider abstraction.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from experimaestro.webui.server import WebUIServer
|
|
8
|
+
|
|
9
|
+
__all__ = ["WebUIServer"]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""FastAPI application for WebUI
|
|
2
|
+
|
|
3
|
+
Creates the FastAPI app with WebSocket endpoint and routes.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from importlib.resources import files
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
|
|
11
|
+
from fastapi.responses import RedirectResponse, Response
|
|
12
|
+
|
|
13
|
+
from experimaestro.webui.websocket import WebSocketHandler
|
|
14
|
+
from experimaestro.webui.state_bridge import StateBridge
|
|
15
|
+
from experimaestro.webui.routes import auth, proxy
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from experimaestro.webui.server import WebUIServer
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("xpm.webui")
|
|
21
|
+
|
|
22
|
+
# MIME types for static files
|
|
23
|
+
MIMETYPES = {
|
|
24
|
+
"html": "text/html",
|
|
25
|
+
"map": "text/plain",
|
|
26
|
+
"txt": "text/plain",
|
|
27
|
+
"ico": "image/x-icon",
|
|
28
|
+
"png": "image/png",
|
|
29
|
+
"css": "text/css",
|
|
30
|
+
"js": "application/javascript",
|
|
31
|
+
"json": "application/json",
|
|
32
|
+
"eot": "font/vnd.ms-fontobject",
|
|
33
|
+
"woff": "font/woff",
|
|
34
|
+
"woff2": "font/woff2",
|
|
35
|
+
"ttf": "font/ttf",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def create_app(server: "WebUIServer") -> FastAPI:
|
|
40
|
+
"""Create FastAPI application
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
server: WebUIServer instance with state_provider and settings
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Configured FastAPI app
|
|
47
|
+
"""
|
|
48
|
+
app = FastAPI(title="Experimaestro WebUI", docs_url=None, redoc_url=None)
|
|
49
|
+
|
|
50
|
+
# Create WebSocket handler
|
|
51
|
+
ws_handler = WebSocketHandler(server.state_provider, server.token)
|
|
52
|
+
|
|
53
|
+
# Create state bridge to forward state events to WebSocket
|
|
54
|
+
state_bridge = StateBridge(server.state_provider, ws_handler)
|
|
55
|
+
|
|
56
|
+
# Store references on app for routes to access
|
|
57
|
+
app.state.server = server
|
|
58
|
+
app.state.ws_handler = ws_handler
|
|
59
|
+
app.state.state_bridge = state_bridge
|
|
60
|
+
|
|
61
|
+
# Include route modules
|
|
62
|
+
app.include_router(auth.router)
|
|
63
|
+
app.include_router(proxy.router)
|
|
64
|
+
|
|
65
|
+
# WebSocket endpoint
|
|
66
|
+
@app.websocket("/ws")
|
|
67
|
+
async def websocket_endpoint(websocket: WebSocket):
|
|
68
|
+
"""Main WebSocket endpoint for real-time updates"""
|
|
69
|
+
await ws_handler.connect(websocket)
|
|
70
|
+
try:
|
|
71
|
+
while True:
|
|
72
|
+
data = await websocket.receive_json()
|
|
73
|
+
await ws_handler.handle_message(websocket, data)
|
|
74
|
+
except WebSocketDisconnect:
|
|
75
|
+
await ws_handler.disconnect(websocket)
|
|
76
|
+
except Exception as e:
|
|
77
|
+
logger.error("WebSocket error: %s", e)
|
|
78
|
+
await ws_handler.disconnect(websocket)
|
|
79
|
+
|
|
80
|
+
# Root route
|
|
81
|
+
@app.get("/")
|
|
82
|
+
async def root(request: Request):
|
|
83
|
+
"""Root redirect based on auth status"""
|
|
84
|
+
# Check cookie for authentication
|
|
85
|
+
token = request.cookies.get("experimaestro_token")
|
|
86
|
+
if token == server.token:
|
|
87
|
+
return RedirectResponse(url="/index.html", status_code=302)
|
|
88
|
+
return RedirectResponse(url="/login.html", status_code=302)
|
|
89
|
+
|
|
90
|
+
# Static file serving (catch-all, must be last)
|
|
91
|
+
@app.get("/{path:path}")
|
|
92
|
+
async def static_files(request: Request, path: str):
|
|
93
|
+
"""Serve static files from data/ directory"""
|
|
94
|
+
# Check authentication for index.html
|
|
95
|
+
if path == "index.html":
|
|
96
|
+
token = request.cookies.get("experimaestro_token")
|
|
97
|
+
if token != server.token:
|
|
98
|
+
return RedirectResponse(url="/login.html", status_code=302)
|
|
99
|
+
|
|
100
|
+
# Get static file
|
|
101
|
+
datapath = f"data/{path}"
|
|
102
|
+
logger.debug("Looking for %s", datapath)
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
package_files = files("experimaestro.webui")
|
|
106
|
+
resource_file = package_files / datapath
|
|
107
|
+
if resource_file.is_file():
|
|
108
|
+
ext = datapath.rsplit(".", 1)[-1]
|
|
109
|
+
mimetype = MIMETYPES.get(ext, "application/octet-stream")
|
|
110
|
+
content = resource_file.read_bytes()
|
|
111
|
+
return Response(content=content, media_type=mimetype)
|
|
112
|
+
except (FileNotFoundError, KeyError):
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
return Response(content="Page not found", status_code=404)
|
|
116
|
+
|
|
117
|
+
return app
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
\**********************************************************************************************************************************************************************************************************************************************/
|
|
4
4
|
@charset "UTF-8";
|
|
5
5
|
/*!
|
|
6
|
-
* Bootstrap v5.3.
|
|
6
|
+
* Bootstrap v5.3.8 (https://getbootstrap.com/)
|
|
7
7
|
* Copyright 2011-2025 The Bootstrap Authors
|
|
8
8
|
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
|
9
9
|
*/
|
|
@@ -520,6 +520,11 @@ legend + * {
|
|
|
520
520
|
outline-offset: -2px;
|
|
521
521
|
}
|
|
522
522
|
|
|
523
|
+
[type=search]::-webkit-search-cancel-button {
|
|
524
|
+
cursor: pointer;
|
|
525
|
+
filter: grayscale(1);
|
|
526
|
+
}
|
|
527
|
+
|
|
523
528
|
::-webkit-search-decoration {
|
|
524
529
|
-webkit-appearance: none;
|
|
525
530
|
}
|
|
@@ -4492,20 +4497,20 @@ textarea.form-control-lg {
|
|
|
4492
4497
|
border-top-right-radius: 0;
|
|
4493
4498
|
border-bottom-right-radius: 0;
|
|
4494
4499
|
}
|
|
4495
|
-
.card-group > .card:not(:last-child) .card-header, .card-group > .card:not(:last-child) .card-img-top {
|
|
4500
|
+
.card-group > .card:not(:last-child) > .card-header, .card-group > .card:not(:last-child) > .card-img-top {
|
|
4496
4501
|
border-top-right-radius: 0;
|
|
4497
4502
|
}
|
|
4498
|
-
.card-group > .card:not(:last-child) .card-footer, .card-group > .card:not(:last-child) .card-img-bottom {
|
|
4503
|
+
.card-group > .card:not(:last-child) > .card-footer, .card-group > .card:not(:last-child) > .card-img-bottom {
|
|
4499
4504
|
border-bottom-right-radius: 0;
|
|
4500
4505
|
}
|
|
4501
4506
|
.card-group > .card:not(:first-child) {
|
|
4502
4507
|
border-top-left-radius: 0;
|
|
4503
4508
|
border-bottom-left-radius: 0;
|
|
4504
4509
|
}
|
|
4505
|
-
.card-group > .card:not(:first-child) .card-header, .card-group > .card:not(:first-child) .card-img-top {
|
|
4510
|
+
.card-group > .card:not(:first-child) > .card-header, .card-group > .card:not(:first-child) > .card-img-top {
|
|
4506
4511
|
border-top-left-radius: 0;
|
|
4507
4512
|
}
|
|
4508
|
-
.card-group > .card:not(:first-child) .card-footer, .card-group > .card:not(:first-child) .card-img-bottom {
|
|
4513
|
+
.card-group > .card:not(:first-child) > .card-footer, .card-group > .card:not(:first-child) > .card-img-bottom {
|
|
4509
4514
|
border-bottom-left-radius: 0;
|
|
4510
4515
|
}
|
|
4511
4516
|
}
|
|
@@ -6232,6 +6237,7 @@ textarea.form-control-lg {
|
|
|
6232
6237
|
|
|
6233
6238
|
.spinner-border, .spinner-grow {
|
|
6234
6239
|
display: inline-block;
|
|
6240
|
+
flex-shrink: 0;
|
|
6235
6241
|
width: var(--bs-spinner-width);
|
|
6236
6242
|
height: var(--bs-spinner-height);
|
|
6237
6243
|
vertical-align: var(--bs-spinner-vertical-align);
|
|
@@ -7196,6 +7202,10 @@ textarea.form-control-lg {
|
|
|
7196
7202
|
position: absolute !important;
|
|
7197
7203
|
}
|
|
7198
7204
|
|
|
7205
|
+
.visually-hidden *, .visually-hidden-focusable:not(:focus):not(:focus-within) * {
|
|
7206
|
+
overflow: hidden !important;
|
|
7207
|
+
}
|
|
7208
|
+
|
|
7199
7209
|
.stretched-link::after {
|
|
7200
7210
|
position: absolute;
|
|
7201
7211
|
top: 0;
|
|
@@ -20375,8 +20385,8 @@ readers do not read off random characters that represent icons */
|
|
|
20375
20385
|
* Copyright 2024 Fonticons, Inc.
|
|
20376
20386
|
*/
|
|
20377
20387
|
:root, :host {
|
|
20378
|
-
--fa-style-family-classic:
|
|
20379
|
-
--fa-font-regular: normal 400 1em/1
|
|
20388
|
+
--fa-style-family-classic: 'Font Awesome 6 Free';
|
|
20389
|
+
--fa-font-regular: normal 400 1em/1 'Font Awesome 6 Free';
|
|
20380
20390
|
}
|
|
20381
20391
|
|
|
20382
20392
|
@font-face {
|
|
@@ -20397,8 +20407,8 @@ readers do not read off random characters that represent icons */
|
|
|
20397
20407
|
* Copyright 2024 Fonticons, Inc.
|
|
20398
20408
|
*/
|
|
20399
20409
|
:root, :host {
|
|
20400
|
-
--fa-style-family-brands:
|
|
20401
|
-
--fa-font-brands: normal 400 1em/1
|
|
20410
|
+
--fa-style-family-brands: 'Font Awesome 6 Brands';
|
|
20411
|
+
--fa-font-brands: normal 400 1em/1 'Font Awesome 6 Brands';
|
|
20402
20412
|
}
|
|
20403
20413
|
|
|
20404
20414
|
@font-face {
|
|
@@ -22539,8 +22549,8 @@ readers do not read off random characters that represent icons */
|
|
|
22539
22549
|
* Copyright 2024 Fonticons, Inc.
|
|
22540
22550
|
*/
|
|
22541
22551
|
:root, :host {
|
|
22542
|
-
--fa-style-family-classic:
|
|
22543
|
-
--fa-font-solid: normal 900 1em/1
|
|
22552
|
+
--fa-style-family-classic: 'Font Awesome 6 Free';
|
|
22553
|
+
--fa-font-solid: normal 900 1em/1 'Font Awesome 6 Free';
|
|
22544
22554
|
}
|
|
22545
22555
|
|
|
22546
22556
|
@font-face {
|
|
@@ -22753,6 +22763,51 @@ readers do not read off random characters that represent icons */
|
|
|
22753
22763
|
font-weight: bold;
|
|
22754
22764
|
}
|
|
22755
22765
|
|
|
22766
|
+
.dependencies {
|
|
22767
|
+
display: inline-block;
|
|
22768
|
+
margin-left: 8px;
|
|
22769
|
+
font-size: 0.75rem;
|
|
22770
|
+
line-height: 1.2rem;
|
|
22771
|
+
}
|
|
22772
|
+
.dependencies .dependencies-label {
|
|
22773
|
+
color: #666;
|
|
22774
|
+
font-style: italic;
|
|
22775
|
+
margin-right: 4px;
|
|
22776
|
+
}
|
|
22777
|
+
.dependencies .dependency {
|
|
22778
|
+
display: inline-block;
|
|
22779
|
+
padding: 2px 6px;
|
|
22780
|
+
margin: 1px 2px;
|
|
22781
|
+
border-radius: 3px;
|
|
22782
|
+
font-size: 0.7rem;
|
|
22783
|
+
font-weight: 500;
|
|
22784
|
+
cursor: default;
|
|
22785
|
+
}
|
|
22786
|
+
.dependencies .dependency.status-done {
|
|
22787
|
+
background: #c8e6c9;
|
|
22788
|
+
color: #2e7d32;
|
|
22789
|
+
}
|
|
22790
|
+
.dependencies .dependency.status-running {
|
|
22791
|
+
background: #fff9c4;
|
|
22792
|
+
color: #f57f17;
|
|
22793
|
+
}
|
|
22794
|
+
.dependencies .dependency.status-waiting {
|
|
22795
|
+
background: #ffe0b2;
|
|
22796
|
+
color: #e65100;
|
|
22797
|
+
}
|
|
22798
|
+
.dependencies .dependency.status-ready {
|
|
22799
|
+
background: #fff59d;
|
|
22800
|
+
color: #827717;
|
|
22801
|
+
}
|
|
22802
|
+
.dependencies .dependency.status-error {
|
|
22803
|
+
background: #ffcdd2;
|
|
22804
|
+
color: #c62828;
|
|
22805
|
+
}
|
|
22806
|
+
.dependencies .dependency.status-unknown {
|
|
22807
|
+
background: #e0e0e0;
|
|
22808
|
+
color: #616161;
|
|
22809
|
+
}
|
|
22810
|
+
|
|
22756
22811
|
body {
|
|
22757
22812
|
font-size: 1rem;
|
|
22758
22813
|
}
|