experimaestro 2.0.0a8__py3-none-any.whl → 2.0.0b8__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 (122) hide show
  1. experimaestro/__init__.py +10 -11
  2. experimaestro/annotations.py +167 -206
  3. experimaestro/cli/__init__.py +278 -7
  4. experimaestro/cli/filter.py +42 -74
  5. experimaestro/cli/jobs.py +157 -106
  6. experimaestro/cli/refactor.py +249 -0
  7. experimaestro/click.py +0 -1
  8. experimaestro/commandline.py +19 -3
  9. experimaestro/connectors/__init__.py +20 -1
  10. experimaestro/connectors/local.py +12 -0
  11. experimaestro/core/arguments.py +182 -46
  12. experimaestro/core/identifier.py +107 -6
  13. experimaestro/core/objects/__init__.py +6 -0
  14. experimaestro/core/objects/config.py +542 -25
  15. experimaestro/core/objects/config_walk.py +20 -0
  16. experimaestro/core/serialization.py +91 -34
  17. experimaestro/core/subparameters.py +164 -0
  18. experimaestro/core/types.py +175 -38
  19. experimaestro/exceptions.py +26 -0
  20. experimaestro/experiments/cli.py +111 -25
  21. experimaestro/generators.py +50 -9
  22. experimaestro/huggingface.py +3 -1
  23. experimaestro/launcherfinder/parser.py +29 -0
  24. experimaestro/launchers/__init__.py +26 -1
  25. experimaestro/launchers/direct.py +12 -0
  26. experimaestro/launchers/slurm/base.py +154 -2
  27. experimaestro/mkdocs/metaloader.py +0 -1
  28. experimaestro/mypy.py +452 -7
  29. experimaestro/notifications.py +63 -13
  30. experimaestro/progress.py +0 -2
  31. experimaestro/rpyc.py +0 -1
  32. experimaestro/run.py +19 -6
  33. experimaestro/scheduler/base.py +510 -125
  34. experimaestro/scheduler/dependencies.py +43 -28
  35. experimaestro/scheduler/dynamic_outputs.py +259 -130
  36. experimaestro/scheduler/experiment.py +256 -31
  37. experimaestro/scheduler/interfaces.py +501 -0
  38. experimaestro/scheduler/jobs.py +216 -206
  39. experimaestro/scheduler/remote/__init__.py +31 -0
  40. experimaestro/scheduler/remote/client.py +874 -0
  41. experimaestro/scheduler/remote/protocol.py +467 -0
  42. experimaestro/scheduler/remote/server.py +423 -0
  43. experimaestro/scheduler/remote/sync.py +144 -0
  44. experimaestro/scheduler/services.py +323 -23
  45. experimaestro/scheduler/state_db.py +437 -0
  46. experimaestro/scheduler/state_provider.py +2766 -0
  47. experimaestro/scheduler/state_sync.py +891 -0
  48. experimaestro/scheduler/workspace.py +52 -10
  49. experimaestro/scriptbuilder.py +7 -0
  50. experimaestro/server/__init__.py +147 -57
  51. experimaestro/server/data/index.css +0 -125
  52. experimaestro/server/data/index.css.map +1 -1
  53. experimaestro/server/data/index.js +194 -58
  54. experimaestro/server/data/index.js.map +1 -1
  55. experimaestro/settings.py +44 -5
  56. experimaestro/sphinx/__init__.py +3 -3
  57. experimaestro/taskglobals.py +20 -0
  58. experimaestro/tests/conftest.py +80 -0
  59. experimaestro/tests/core/test_generics.py +2 -2
  60. experimaestro/tests/identifier_stability.json +45 -0
  61. experimaestro/tests/launchers/bin/sacct +6 -2
  62. experimaestro/tests/launchers/bin/sbatch +4 -2
  63. experimaestro/tests/launchers/test_slurm.py +80 -0
  64. experimaestro/tests/tasks/test_dynamic.py +231 -0
  65. experimaestro/tests/test_cli_jobs.py +615 -0
  66. experimaestro/tests/test_deprecated.py +630 -0
  67. experimaestro/tests/test_environment.py +200 -0
  68. experimaestro/tests/test_file_progress_integration.py +1 -1
  69. experimaestro/tests/test_forward.py +3 -3
  70. experimaestro/tests/test_identifier.py +372 -41
  71. experimaestro/tests/test_identifier_stability.py +458 -0
  72. experimaestro/tests/test_instance.py +3 -3
  73. experimaestro/tests/test_multitoken.py +442 -0
  74. experimaestro/tests/test_mypy.py +433 -0
  75. experimaestro/tests/test_objects.py +312 -5
  76. experimaestro/tests/test_outputs.py +2 -2
  77. experimaestro/tests/test_param.py +8 -12
  78. experimaestro/tests/test_partial_paths.py +231 -0
  79. experimaestro/tests/test_progress.py +0 -48
  80. experimaestro/tests/test_remote_state.py +671 -0
  81. experimaestro/tests/test_resumable_task.py +480 -0
  82. experimaestro/tests/test_serializers.py +141 -1
  83. experimaestro/tests/test_state_db.py +434 -0
  84. experimaestro/tests/test_subparameters.py +160 -0
  85. experimaestro/tests/test_tags.py +136 -0
  86. experimaestro/tests/test_tasks.py +107 -121
  87. experimaestro/tests/test_token_locking.py +252 -0
  88. experimaestro/tests/test_tokens.py +17 -13
  89. experimaestro/tests/test_types.py +123 -1
  90. experimaestro/tests/test_workspace_triggers.py +158 -0
  91. experimaestro/tests/token_reschedule.py +4 -2
  92. experimaestro/tests/utils.py +2 -2
  93. experimaestro/tokens.py +154 -57
  94. experimaestro/tools/diff.py +1 -1
  95. experimaestro/tui/__init__.py +8 -0
  96. experimaestro/tui/app.py +2395 -0
  97. experimaestro/tui/app.tcss +353 -0
  98. experimaestro/tui/log_viewer.py +228 -0
  99. experimaestro/utils/__init__.py +23 -0
  100. experimaestro/utils/environment.py +148 -0
  101. experimaestro/utils/git.py +129 -0
  102. experimaestro/utils/resources.py +1 -1
  103. experimaestro/version.py +34 -0
  104. {experimaestro-2.0.0a8.dist-info → experimaestro-2.0.0b8.dist-info}/METADATA +68 -38
  105. experimaestro-2.0.0b8.dist-info/RECORD +187 -0
  106. {experimaestro-2.0.0a8.dist-info → experimaestro-2.0.0b8.dist-info}/WHEEL +1 -1
  107. experimaestro-2.0.0b8.dist-info/entry_points.txt +16 -0
  108. experimaestro/compat.py +0 -6
  109. experimaestro/core/objects.pyi +0 -221
  110. experimaestro/server/data/0c35d18bf06992036b69.woff2 +0 -0
  111. experimaestro/server/data/219aa9140e099e6c72ed.woff2 +0 -0
  112. experimaestro/server/data/3a4004a46a653d4b2166.woff +0 -0
  113. experimaestro/server/data/3baa5b8f3469222b822d.woff +0 -0
  114. experimaestro/server/data/4d73cb90e394b34b7670.woff +0 -0
  115. experimaestro/server/data/4ef4218c522f1eb6b5b1.woff2 +0 -0
  116. experimaestro/server/data/5d681e2edae8c60630db.woff +0 -0
  117. experimaestro/server/data/6f420cf17cc0d7676fad.woff2 +0 -0
  118. experimaestro/server/data/c380809fd3677d7d6903.woff2 +0 -0
  119. experimaestro/server/data/f882956fd323fd322f31.woff +0 -0
  120. experimaestro-2.0.0a8.dist-info/RECORD +0 -166
  121. experimaestro-2.0.0a8.dist-info/entry_points.txt +0 -17
  122. {experimaestro-2.0.0a8.dist-info → experimaestro-2.0.0b8.dist-info}/licenses/LICENSE +0 -0
@@ -1,7 +1,6 @@
1
1
  # flake8: noqa: T201
2
2
  import sys
3
3
  from typing import Set, Optional
4
- import pkg_resources
5
4
  from itertools import chain
6
5
  from shutil import rmtree
7
6
  import click
@@ -10,11 +9,12 @@ from functools import cached_property, update_wrapper
10
9
  from pathlib import Path
11
10
  import subprocess
12
11
  from termcolor import cprint
12
+ from importlib.metadata import entry_points
13
13
 
14
14
  import experimaestro
15
15
  from experimaestro.experiments.cli import experiments_cli
16
16
  import experimaestro.launcherfinder.registry as launcher_registry
17
- from experimaestro.settings import find_workspace
17
+ from experimaestro.settings import ServerSettings, find_workspace
18
18
 
19
19
  # --- Command line main options
20
20
  logging.basicConfig(level=logging.INFO)
@@ -257,13 +257,13 @@ def find_launchers(config: Optional[Path], spec: str):
257
257
  print(launcher_registry.find_launcher(spec))
258
258
 
259
259
 
260
- class Launchers(click.MultiCommand):
261
- """Connectors commands"""
260
+ class Launchers(click.Group):
261
+ """Dynamic command group for entry point discovery"""
262
262
 
263
263
  @cached_property
264
264
  def commands(self):
265
265
  map = {}
266
- for ep in pkg_resources.iter_entry_points(f"experimaestro.{self.name}"):
266
+ for ep in entry_points(group=f"experimaestro.{self.name}"):
267
267
  if get_cli := getattr(ep.load(), "get_cli", None):
268
268
  map[ep.name] = get_cli()
269
269
  return map
@@ -289,6 +289,11 @@ from .jobs import jobs as jobs_cli
289
289
 
290
290
  cli.add_command(jobs_cli)
291
291
 
292
+ # Import and add refactor commands
293
+ from .refactor import refactor as refactor_cli
294
+
295
+ cli.add_command(refactor_cli)
296
+
292
297
 
293
298
  @cli.group()
294
299
  @click.option("--workdir", type=Path, default=None)
@@ -304,8 +309,274 @@ def experiments(ctx, workdir, workspace):
304
309
  @experiments.command()
305
310
  @pass_cfg
306
311
  def list(workdir: Path):
312
+ """List experiments in the workspace"""
313
+ from experimaestro.scheduler.state_provider import WorkspaceStateProvider
314
+
315
+ # Get experiments from state provider for detailed info
316
+ state_provider = WorkspaceStateProvider.get_instance(
317
+ workdir, read_only=True, sync_on_start=True
318
+ )
319
+ experiments_list = state_provider.get_experiments()
320
+
321
+ # Build lookup by experiment_id
322
+ exp_info = {exp.experiment_id: exp for exp in experiments_list}
323
+
307
324
  for p in (workdir / "xp").iterdir():
325
+ exp_id = p.name
326
+ exp = exp_info.get(exp_id)
327
+
328
+ # Build display string
329
+ display_parts = []
330
+
308
331
  if (p / "jobs.bak").exists():
309
- cprint(f"[unfinished] {p.name}", "yellow")
332
+ display_parts.append("[unfinished]")
333
+
334
+ display_parts.append(exp_id)
335
+
336
+ # Add hostname if available
337
+ if exp and getattr(exp, "hostname", None):
338
+ display_parts.append(f"[{exp.hostname}]")
339
+
340
+ # Add job stats if available
341
+ if exp:
342
+ display_parts.append(f"({exp.finished_jobs}/{exp.total_jobs} jobs)")
343
+
344
+ display_str = " ".join(display_parts)
345
+
346
+ if (p / "jobs.bak").exists():
347
+ cprint(display_str, "yellow")
348
+ else:
349
+ cprint(display_str, "cyan")
350
+
351
+
352
+ def _run_monitor_ui(
353
+ state_provider, workdir: Path, console: bool, port: int, title: str = ""
354
+ ):
355
+ """Shared code for running monitor UI (TUI or web)
356
+
357
+ Args:
358
+ state_provider: StateProvider instance (local or remote)
359
+ workdir: Local workspace/cache directory
360
+ console: If True, use TUI; otherwise use web UI
361
+ port: Port for web server
362
+ title: Optional title for status messages
363
+ """
364
+ try:
365
+ if console:
366
+ # Use Textual TUI
367
+ from experimaestro.tui import ExperimentTUI
368
+
369
+ app = ExperimentTUI(
370
+ workdir, state_provider=state_provider, watch=True, show_logs=True
371
+ )
372
+ app.run()
310
373
  else:
311
- cprint(p.name, "cyan")
374
+ # Use React web server
375
+ from experimaestro.server import Server
376
+
377
+ if title:
378
+ cprint(
379
+ f"Starting experiment monitor for {title} on http://localhost:{port}",
380
+ "green",
381
+ )
382
+ else:
383
+ cprint(
384
+ f"Starting experiment monitor on http://localhost:{port}", "green"
385
+ )
386
+ cprint("Press Ctrl+C to stop", "yellow")
387
+
388
+ settings = ServerSettings()
389
+ settings.port = port
390
+ server = Server.instance(settings, state_provider=state_provider)
391
+ server.start()
392
+
393
+ try:
394
+ import time
395
+
396
+ while True:
397
+ time.sleep(1)
398
+ except KeyboardInterrupt:
399
+ pass
400
+ finally:
401
+ cprint("\nShutting down...", "yellow")
402
+ if state_provider:
403
+ state_provider.close()
404
+
405
+
406
+ @experiments.command()
407
+ @click.option("--console", is_flag=True, help="Use console TUI instead of web UI")
408
+ @click.option(
409
+ "--port", type=int, default=12345, help="Port for web server (default: 12345)"
410
+ )
411
+ @click.option(
412
+ "--sync", is_flag=True, help="Force sync from disk before starting monitor"
413
+ )
414
+ @pass_cfg
415
+ def monitor(workdir: Path, console: bool, port: int, sync: bool):
416
+ """Monitor local experiments with web UI or console TUI"""
417
+ # Force sync from disk if requested
418
+ if sync:
419
+ from experimaestro.scheduler.state_sync import sync_workspace_from_disk
420
+
421
+ cprint("Syncing workspace from disk...", "yellow")
422
+ sync_workspace_from_disk(workdir, write_mode=True, force=True)
423
+ cprint("Sync complete", "green")
424
+
425
+ from experimaestro.scheduler.state_provider import WorkspaceStateProvider
426
+
427
+ state_provider = WorkspaceStateProvider.get_instance(
428
+ workdir,
429
+ sync_on_start=not sync, # Skip auto-sync if we just did a forced one
430
+ )
431
+
432
+ _run_monitor_ui(state_provider, workdir, console, port)
433
+
434
+
435
+ @experiments.command("ssh-monitor")
436
+ @click.argument("host", type=str)
437
+ @click.argument("remote_workdir", type=str)
438
+ @click.option("--console", is_flag=True, help="Use console TUI instead of web UI")
439
+ @click.option(
440
+ "--port", type=int, default=12345, help="Port for web server (default: 12345)"
441
+ )
442
+ @click.option(
443
+ "--remote-xpm",
444
+ type=str,
445
+ default=None,
446
+ help="Path to experimaestro on remote host (default: use 'uv tool run')",
447
+ )
448
+ @click.option(
449
+ "--ssh-option",
450
+ "-o",
451
+ multiple=True,
452
+ help="Additional SSH options (can be repeated, e.g., -o '-p 2222')",
453
+ )
454
+ def ssh_monitor(
455
+ host: str,
456
+ remote_workdir: str,
457
+ console: bool,
458
+ port: int,
459
+ remote_xpm: str,
460
+ ssh_option: tuple,
461
+ ):
462
+ """Monitor experiments on a remote server via SSH
463
+
464
+ HOST is the SSH host (e.g., user@server)
465
+ REMOTE_WORKDIR is the workspace path on the remote server
466
+
467
+ Examples:
468
+ experimaestro experiments ssh-monitor myserver /path/to/workspace
469
+ experimaestro experiments ssh-monitor user@host /workspace --console
470
+ experimaestro experiments ssh-monitor host /workspace --remote-xpm /opt/xpm/bin/experimaestro
471
+ """
472
+ from experimaestro.scheduler.remote.client import SSHStateProviderClient
473
+
474
+ cprint(f"Connecting to {host}...", "yellow")
475
+ state_provider = SSHStateProviderClient(
476
+ host=host,
477
+ remote_workspace=remote_workdir,
478
+ ssh_options=list(ssh_option) if ssh_option else None,
479
+ remote_xpm_path=remote_xpm,
480
+ )
481
+ try:
482
+ state_provider.connect()
483
+ cprint(f"Connected to {host}", "green")
484
+ except Exception as e:
485
+ cprint(f"Failed to connect: {e}", "red")
486
+ raise click.Abort()
487
+
488
+ _run_monitor_ui(
489
+ state_provider,
490
+ state_provider.local_cache_dir,
491
+ console,
492
+ port,
493
+ title=host,
494
+ )
495
+
496
+
497
+ @experiments.command("monitor-server")
498
+ @pass_cfg
499
+ def monitor_server(workdir: Path):
500
+ """Start monitoring server for SSH connections (JSON-RPC over stdio)
501
+
502
+ This command is intended to be run over SSH to provide remote monitoring.
503
+ Communication is via JSON-RPC over stdin/stdout.
504
+
505
+ Example:
506
+ ssh host 'experimaestro experiments --workdir /path monitor-server'
507
+ """
508
+ from experimaestro.scheduler.remote.server import SSHStateProviderServer
509
+
510
+ server = SSHStateProviderServer(workdir)
511
+ try:
512
+ server.start()
513
+ except KeyboardInterrupt:
514
+ server.stop()
515
+
516
+
517
+ @experiments.command()
518
+ @click.option(
519
+ "--dry-run",
520
+ is_flag=True,
521
+ help="Don't write to database, only show what would be synced",
522
+ )
523
+ @click.option(
524
+ "--force",
525
+ is_flag=True,
526
+ help="Force sync even if recently synced (bypasses time throttling)",
527
+ )
528
+ @click.option(
529
+ "--no-wait",
530
+ is_flag=True,
531
+ help="Don't wait for lock, fail immediately if unavailable",
532
+ )
533
+ @pass_cfg
534
+ def sync(workdir: Path, dry_run: bool, force: bool, no_wait: bool):
535
+ """Synchronize workspace database from disk state
536
+
537
+ Scans experiment directories and job marker files to update the workspace
538
+ database. Uses exclusive locking to prevent conflicts with running experiments.
539
+ """
540
+ from experimaestro.scheduler.state_sync import sync_workspace_from_disk
541
+ from experimaestro.scheduler.workspace import Workspace
542
+ from experimaestro.settings import Settings
543
+
544
+ # Get settings and workspace settings
545
+ settings = Settings.instance()
546
+ ws_settings = find_workspace(workdir=workdir)
547
+
548
+ # Create workspace instance (manages database lifecycle)
549
+ workspace = Workspace(
550
+ settings=settings,
551
+ workspace_settings=ws_settings,
552
+ sync_on_init=False, # Don't sync on init since we're explicitly syncing
553
+ )
554
+
555
+ try:
556
+ # Enter workspace context to initialize database
557
+ with workspace:
558
+ cprint(f"Syncing workspace: {workspace.path}", "cyan")
559
+ if dry_run:
560
+ cprint("DRY RUN MODE: No changes will be written", "yellow")
561
+ if force:
562
+ cprint("FORCE MODE: Bypassing time throttling", "yellow")
563
+
564
+ # Run sync
565
+ sync_workspace_from_disk(
566
+ workspace=workspace,
567
+ write_mode=not dry_run,
568
+ force=force,
569
+ blocking=not no_wait,
570
+ )
571
+
572
+ cprint("Sync completed successfully", "green")
573
+
574
+ except RuntimeError as e:
575
+ cprint(f"Sync failed: {e}", "red")
576
+ sys.exit(1)
577
+ except Exception as e:
578
+ cprint(f"Unexpected error during sync: {e}", "red")
579
+ import traceback
580
+
581
+ traceback.print_exc()
582
+ sys.exit(1)
@@ -1,57 +1,15 @@
1
- import asyncio
2
- import logging
3
- from typing import Any, Callable, Dict, List, Optional
4
- import pyparsing as pp
5
- from pathlib import Path
6
- import json
7
- from experimaestro.compat import cached_property
1
+ """Filter expressions for job queries
2
+
3
+ This module provides a filter expression parser for querying jobs by state,
4
+ tags, and other attributes.
5
+ """
6
+
8
7
  import re
9
- from experimaestro.scheduler import JobState
10
-
11
-
12
- class JobInformation:
13
- def __init__(self, path: Path, scriptname: str, check: bool = False):
14
- self.path = path
15
- self.scriptname = scriptname
16
- self.check = check
17
-
18
- @cached_property
19
- def params(self):
20
- try:
21
- return json.loads((self.path / "params.json").read_text())
22
- except Exception:
23
- logging.warning("Could not load params.json in %s", self.path)
24
- return {"tags": {}}
25
-
26
- @cached_property
27
- def tags(self) -> List[str]:
28
- return self.params["tags"]
29
-
30
- @cached_property
31
- def state(self) -> Optional[JobState]:
32
- if (self.path / f"{self.scriptname}.done").is_file():
33
- return JobState.DONE
34
- if (self.path / f"{self.scriptname}.failed").is_file():
35
- return JobState.ERROR
36
- if (self.path / f"{self.scriptname}.pid").is_file():
37
- if self.check:
38
- if process := self.getprocess():
39
- state = asyncio.run(process.aio_state(0))
40
- if state is None or state.finished:
41
- return JobState.ERROR
42
- else:
43
- return JobState.ERROR
44
- return JobState.RUNNING
45
- else:
46
- return None
47
-
48
- def getprocess(self):
49
- from experimaestro.connectors import Process
50
- from experimaestro.connectors.local import LocalConnector
51
-
52
- connector = LocalConnector.instance()
53
- pinfo = json.loads((self.path / f"{self.scriptname}.pid").read_text())
54
- return Process.fromDefinition(connector, pinfo)
8
+ from typing import Callable, TYPE_CHECKING
9
+ import pyparsing as pp
10
+
11
+ if TYPE_CHECKING:
12
+ from experimaestro.scheduler.state_provider import MockJob
55
13
 
56
14
 
57
15
  # --- classes for processing
@@ -61,14 +19,14 @@ class VarExpr:
61
19
  def __init__(self, values):
62
20
  (self.varname,) = values
63
21
 
64
- def get(self, info: JobInformation):
22
+ def get(self, job: "MockJob"):
65
23
  if self.varname == "@state":
66
- return info.state.name if info.state else None
24
+ return job.state.name if job.state else None
67
25
 
68
26
  if self.varname == "@name":
69
- return str(info.path.parent.name)
27
+ return str(job.path.parent.name)
70
28
 
71
- return info.tags.get(self.varname, None)
29
+ return job.tags.get(self.varname, None)
72
30
 
73
31
  def __repr__(self):
74
32
  return f"""VAR<{self.varname}>"""
@@ -81,8 +39,8 @@ class BaseInExpr:
81
39
 
82
40
 
83
41
  class InExpr(BaseInExpr):
84
- def filter(self, information: JobInformation):
85
- value = self.var.get(information)
42
+ def filter(self, job: "MockJob"):
43
+ value = self.var.get(job)
86
44
  return value in self.values
87
45
 
88
46
  def __repr__(self):
@@ -90,8 +48,8 @@ class InExpr(BaseInExpr):
90
48
 
91
49
 
92
50
  class NotInExpr(BaseInExpr):
93
- def filter(self, information: JobInformation):
94
- value = self.var.get(information)
51
+ def filter(self, job: "MockJob"):
52
+ value = self.var.get(job)
95
53
  return value not in self.values
96
54
 
97
55
  def __repr__(self):
@@ -106,25 +64,25 @@ class RegexExpr:
106
64
  def __repr__(self):
107
65
  return f"""REGEX[{self.varname}, {self.value}]"""
108
66
 
109
- def matches(self, manager, publication):
67
+ def matches(self, _manager, publication):
110
68
  if self.varname == "tag":
111
69
  return self.value in publication.tags
112
70
 
113
71
  raise AssertionError()
114
72
 
115
- def filter(self, information: JobInformation):
116
- value = self.var.get(information)
73
+ def filter(self, job: "MockJob"):
74
+ value = self.var.get(job)
117
75
  if not value:
118
76
  return False
119
77
 
120
- return self.re.match(value)
78
+ return self.regex.match(value)
121
79
 
122
80
 
123
81
  class ConstantString:
124
82
  def __init__(self, tokens):
125
83
  (self.value,) = tokens
126
84
 
127
- def get(self, information: JobInformation):
85
+ def get(self, _job: "MockJob"):
128
86
  return self.value
129
87
 
130
88
  def __repr__(self):
@@ -138,8 +96,8 @@ class EqExpr:
138
96
  def __repr__(self):
139
97
  return f"""EQ[{self.var1}, {self.var2}]"""
140
98
 
141
- def filter(self, information: JobInformation):
142
- return self.var1.get(information) == self.var2.get(information)
99
+ def filter(self, job: "MockJob"):
100
+ return self.var1.get(job) == self.var2.get(job)
143
101
 
144
102
 
145
103
  class LogicExpr:
@@ -149,11 +107,11 @@ class LogicExpr:
149
107
  self.operator, self.y = tokens
150
108
  self.x = None
151
109
 
152
- def filter(self, information: JobInformation):
110
+ def filter(self, job: "MockJob"):
153
111
  if self.operator == "and":
154
- return self.y.filter(information) and self.x.filter(information)
112
+ return self.y.filter(job) and self.x.filter(job)
155
113
 
156
- return self.y.filter(information) or self.x.filter(information)
114
+ return self.y.filter(job) or self.x.filter(job)
157
115
 
158
116
  @staticmethod
159
117
  def summary(tokens):
@@ -187,7 +145,10 @@ quotedString = pp.QuotedString('"', unquoteResults=True) | pp.QuotedString(
187
145
  "'", unquoteResults=True
188
146
  )
189
147
 
190
- var = l("@state") | l("@name") | pp.Word(pp.alphas)
148
+ # Tag names can contain letters, digits, underscores, and hyphens
149
+ # First character must be a letter, rest can include digits, underscores, hyphens
150
+ tag_name = pp.Word(pp.alphas, pp.alphanums + "_-")
151
+ var = l("@state") | l("@name") | tag_name
191
152
  var.setParseAction(VarExpr)
192
153
 
193
154
  regexExpr = var + tilde + quotedString
@@ -220,7 +181,14 @@ filterExpr = (
220
181
  expr = (matchExpr + pp.Optional(pipe + filterExpr)).setParseAction(LogicExpr.generator)
221
182
 
222
183
 
223
- def createFilter(query: str) -> Callable[[Dict[str, Any]], bool]:
224
- """Returns a filter object given a query"""
184
+ def createFilter(query: str) -> Callable[["MockJob"], bool]:
185
+ """Returns a filter function given a query string
186
+
187
+ Args:
188
+ query: Filter expression (e.g., '@state = "DONE" and model = "bm25"')
189
+
190
+ Returns:
191
+ A callable that takes a MockJob and returns True if it matches
192
+ """
225
193
  (r,) = logicExpr.parseString(query, parseAll=True)
226
194
  return r.filter