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

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

Potentially problematic release.


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

Files changed (152) hide show
  1. experimaestro/__init__.py +12 -5
  2. experimaestro/cli/__init__.py +239 -126
  3. experimaestro/cli/filter.py +48 -23
  4. experimaestro/cli/jobs.py +253 -71
  5. experimaestro/cli/refactor.py +1 -2
  6. experimaestro/commandline.py +7 -4
  7. experimaestro/connectors/__init__.py +9 -1
  8. experimaestro/connectors/local.py +43 -3
  9. experimaestro/core/arguments.py +18 -18
  10. experimaestro/core/identifier.py +11 -11
  11. experimaestro/core/objects/config.py +96 -39
  12. experimaestro/core/objects/config_walk.py +3 -3
  13. experimaestro/core/{subparameters.py → partial.py} +16 -16
  14. experimaestro/core/partial_lock.py +394 -0
  15. experimaestro/core/types.py +12 -15
  16. experimaestro/dynamic.py +290 -0
  17. experimaestro/experiments/__init__.py +6 -2
  18. experimaestro/experiments/cli.py +217 -50
  19. experimaestro/experiments/configuration.py +24 -0
  20. experimaestro/generators.py +5 -5
  21. experimaestro/ipc.py +118 -1
  22. experimaestro/launcherfinder/__init__.py +2 -2
  23. experimaestro/launcherfinder/registry.py +6 -7
  24. experimaestro/launcherfinder/specs.py +2 -9
  25. experimaestro/launchers/slurm/__init__.py +2 -2
  26. experimaestro/launchers/slurm/base.py +62 -0
  27. experimaestro/locking.py +957 -1
  28. experimaestro/notifications.py +89 -201
  29. experimaestro/progress.py +63 -366
  30. experimaestro/rpyc.py +0 -2
  31. experimaestro/run.py +29 -2
  32. experimaestro/scheduler/__init__.py +8 -1
  33. experimaestro/scheduler/base.py +629 -53
  34. experimaestro/scheduler/dependencies.py +20 -16
  35. experimaestro/scheduler/experiment.py +732 -167
  36. experimaestro/scheduler/interfaces.py +316 -101
  37. experimaestro/scheduler/jobs.py +58 -20
  38. experimaestro/scheduler/remote/adaptive_sync.py +265 -0
  39. experimaestro/scheduler/remote/client.py +171 -117
  40. experimaestro/scheduler/remote/protocol.py +8 -193
  41. experimaestro/scheduler/remote/server.py +95 -71
  42. experimaestro/scheduler/services.py +53 -28
  43. experimaestro/scheduler/state_provider.py +663 -2430
  44. experimaestro/scheduler/state_status.py +1247 -0
  45. experimaestro/scheduler/transient.py +31 -0
  46. experimaestro/scheduler/workspace.py +1 -1
  47. experimaestro/scheduler/workspace_state_provider.py +1273 -0
  48. experimaestro/scriptbuilder.py +4 -4
  49. experimaestro/settings.py +36 -0
  50. experimaestro/tests/conftest.py +33 -5
  51. experimaestro/tests/connectors/bin/executable.py +1 -1
  52. experimaestro/tests/fixtures/pre_experiment/experiment_check_env.py +16 -0
  53. experimaestro/tests/fixtures/pre_experiment/experiment_check_mock.py +14 -0
  54. experimaestro/tests/fixtures/pre_experiment/experiment_simple.py +12 -0
  55. experimaestro/tests/fixtures/pre_experiment/pre_setup_env.py +5 -0
  56. experimaestro/tests/fixtures/pre_experiment/pre_setup_error.py +3 -0
  57. experimaestro/tests/fixtures/pre_experiment/pre_setup_mock.py +8 -0
  58. experimaestro/tests/launchers/bin/test.py +1 -0
  59. experimaestro/tests/launchers/test_slurm.py +9 -9
  60. experimaestro/tests/partial_reschedule.py +46 -0
  61. experimaestro/tests/restart.py +3 -3
  62. experimaestro/tests/restart_main.py +1 -0
  63. experimaestro/tests/scripts/notifyandwait.py +1 -0
  64. experimaestro/tests/task_partial.py +38 -0
  65. experimaestro/tests/task_tokens.py +2 -2
  66. experimaestro/tests/tasks/test_dynamic.py +6 -6
  67. experimaestro/tests/test_dependencies.py +3 -3
  68. experimaestro/tests/test_deprecated.py +15 -15
  69. experimaestro/tests/test_dynamic_locking.py +317 -0
  70. experimaestro/tests/test_environment.py +24 -14
  71. experimaestro/tests/test_experiment.py +171 -36
  72. experimaestro/tests/test_identifier.py +25 -25
  73. experimaestro/tests/test_identifier_stability.py +3 -5
  74. experimaestro/tests/test_multitoken.py +2 -4
  75. experimaestro/tests/{test_subparameters.py → test_partial.py} +25 -25
  76. experimaestro/tests/test_partial_paths.py +81 -138
  77. experimaestro/tests/test_pre_experiment.py +219 -0
  78. experimaestro/tests/test_progress.py +2 -8
  79. experimaestro/tests/test_remote_state.py +560 -99
  80. experimaestro/tests/test_stray_jobs.py +261 -0
  81. experimaestro/tests/test_tasks.py +1 -2
  82. experimaestro/tests/test_token_locking.py +52 -67
  83. experimaestro/tests/test_tokens.py +5 -6
  84. experimaestro/tests/test_transient.py +225 -0
  85. experimaestro/tests/test_workspace_state_provider.py +768 -0
  86. experimaestro/tests/token_reschedule.py +1 -3
  87. experimaestro/tests/utils.py +2 -7
  88. experimaestro/tokens.py +227 -372
  89. experimaestro/tools/diff.py +1 -0
  90. experimaestro/tools/documentation.py +4 -5
  91. experimaestro/tools/jobs.py +1 -2
  92. experimaestro/tui/app.py +438 -1966
  93. experimaestro/tui/app.tcss +162 -0
  94. experimaestro/tui/dialogs.py +172 -0
  95. experimaestro/tui/log_viewer.py +253 -3
  96. experimaestro/tui/messages.py +137 -0
  97. experimaestro/tui/utils.py +54 -0
  98. experimaestro/tui/widgets/__init__.py +23 -0
  99. experimaestro/tui/widgets/experiments.py +468 -0
  100. experimaestro/tui/widgets/global_services.py +238 -0
  101. experimaestro/tui/widgets/jobs.py +972 -0
  102. experimaestro/tui/widgets/log.py +156 -0
  103. experimaestro/tui/widgets/orphans.py +363 -0
  104. experimaestro/tui/widgets/runs.py +185 -0
  105. experimaestro/tui/widgets/services.py +314 -0
  106. experimaestro/tui/widgets/stray_jobs.py +528 -0
  107. experimaestro/utils/__init__.py +1 -1
  108. experimaestro/utils/environment.py +105 -22
  109. experimaestro/utils/fswatcher.py +124 -0
  110. experimaestro/utils/jobs.py +1 -2
  111. experimaestro/utils/jupyter.py +1 -2
  112. experimaestro/utils/logging.py +72 -0
  113. experimaestro/version.py +2 -2
  114. experimaestro/webui/__init__.py +9 -0
  115. experimaestro/webui/app.py +117 -0
  116. experimaestro/{server → webui}/data/index.css +66 -11
  117. experimaestro/webui/data/index.css.map +1 -0
  118. experimaestro/{server → webui}/data/index.js +82763 -87217
  119. experimaestro/webui/data/index.js.map +1 -0
  120. experimaestro/webui/routes/__init__.py +5 -0
  121. experimaestro/webui/routes/auth.py +53 -0
  122. experimaestro/webui/routes/proxy.py +117 -0
  123. experimaestro/webui/server.py +200 -0
  124. experimaestro/webui/state_bridge.py +152 -0
  125. experimaestro/webui/websocket.py +413 -0
  126. {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/METADATA +5 -6
  127. experimaestro-2.0.0b17.dist-info/RECORD +219 -0
  128. experimaestro/cli/progress.py +0 -269
  129. experimaestro/scheduler/state.py +0 -75
  130. experimaestro/scheduler/state_db.py +0 -437
  131. experimaestro/scheduler/state_sync.py +0 -891
  132. experimaestro/server/__init__.py +0 -467
  133. experimaestro/server/data/index.css.map +0 -1
  134. experimaestro/server/data/index.js.map +0 -1
  135. experimaestro/tests/test_cli_jobs.py +0 -615
  136. experimaestro/tests/test_file_progress.py +0 -425
  137. experimaestro/tests/test_file_progress_integration.py +0 -477
  138. experimaestro/tests/test_state_db.py +0 -434
  139. experimaestro-2.0.0b8.dist-info/RECORD +0 -187
  140. /experimaestro/{server → webui}/data/1815e00441357e01619e.ttf +0 -0
  141. /experimaestro/{server → webui}/data/2463b90d9a316e4e5294.woff2 +0 -0
  142. /experimaestro/{server → webui}/data/2582b0e4bcf85eceead0.ttf +0 -0
  143. /experimaestro/{server → webui}/data/89999bdf5d835c012025.woff2 +0 -0
  144. /experimaestro/{server → webui}/data/914997e1bdfc990d0897.ttf +0 -0
  145. /experimaestro/{server → webui}/data/c210719e60948b211a12.woff2 +0 -0
  146. /experimaestro/{server → webui}/data/favicon.ico +0 -0
  147. /experimaestro/{server → webui}/data/index.html +0 -0
  148. /experimaestro/{server → webui}/data/login.html +0 -0
  149. /experimaestro/{server → webui}/data/manifest.json +0 -0
  150. {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/WHEEL +0 -0
  151. {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/entry_points.txt +0 -0
  152. {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/licenses/LICENSE +0 -0
@@ -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 save_environment_info(path: Path) -> dict:
114
- """Save environment information to a JSON file
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
- The environment info dictionary that was saved
209
+ ExperimentEnvironment with current Python version, packages, etc.
121
210
  """
122
- import json
123
-
124
- env_info = get_environment_info()
125
- path.write_text(json.dumps(env_info, indent=2))
126
- logger.info("Saved environment info to %s", path)
127
- return env_info
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[dict]:
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
- Environment info dictionary if file exists and is valid, None otherwise
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
- try:
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)
@@ -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:
@@ -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.0b8'
32
- __version_tuple__ = version_tuple = (2, 0, 0, 'b8')
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.5 (https://getbootstrap.com/)
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: "Font Awesome 6 Free";
20379
- --fa-font-regular: normal 400 1em/1 "Font Awesome 6 Free";
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: "Font Awesome 6 Brands";
20401
- --fa-font-brands: normal 400 1em/1 "Font Awesome 6 Brands";
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: "Font Awesome 6 Free";
22543
- --fa-font-solid: normal 900 1em/1 "Font Awesome 6 Free";
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
  }