graflag 1.0.0__tar.gz

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.
Files changed (41) hide show
  1. graflag-1.0.0/PKG-INFO +15 -0
  2. graflag-1.0.0/README.md +122 -0
  3. graflag-1.0.0/graflag/__init__.py +20 -0
  4. graflag-1.0.0/graflag/api.py +217 -0
  5. graflag-1.0.0/graflag/cli.py +321 -0
  6. graflag-1.0.0/graflag/config.py +128 -0
  7. graflag-1.0.0/graflag/core.py +735 -0
  8. graflag-1.0.0/graflag/devcluster/__init__.py +1 -0
  9. graflag-1.0.0/graflag/devcluster/cli.py +75 -0
  10. graflag-1.0.0/graflag/devcluster/deploy.sh +112 -0
  11. graflag-1.0.0/graflag/devcluster/docker-compose.yml +122 -0
  12. graflag-1.0.0/graflag/devcluster/hosts.yml +8 -0
  13. graflag-1.0.0/graflag/devcluster/manager/Dockerfile.manager +107 -0
  14. graflag-1.0.0/graflag/devcluster/manager/nvidia-setup.sh +174 -0
  15. graflag-1.0.0/graflag/devcluster/manager/registry-setup.sh +30 -0
  16. graflag-1.0.0/graflag/devcluster/manager/setup-nfs.sh +87 -0
  17. graflag-1.0.0/graflag/devcluster/worker/Dockerfile.worker +101 -0
  18. graflag-1.0.0/graflag/devcluster/worker/nvidia-setup.sh +171 -0
  19. graflag-1.0.0/graflag/devcluster/worker/registry-setup.sh +30 -0
  20. graflag-1.0.0/graflag/devcluster/worker/setup-nfs.sh +74 -0
  21. graflag-1.0.0/graflag/docker_ops.py +670 -0
  22. graflag-1.0.0/graflag/gui/__init__.py +1 -0
  23. graflag-1.0.0/graflag/gui/server.py +490 -0
  24. graflag-1.0.0/graflag/gui/static/css/style.css +880 -0
  25. graflag-1.0.0/graflag/gui/static/js/app.js +656 -0
  26. graflag-1.0.0/graflag/gui/static/js/components/ClusterStatus.js +58 -0
  27. graflag-1.0.0/graflag/gui/static/js/components/DataTable.js +124 -0
  28. graflag-1.0.0/graflag/gui/static/js/components/ExperimentModal.js +21 -0
  29. graflag-1.0.0/graflag/gui/static/js/components/RunForm.js +338 -0
  30. graflag-1.0.0/graflag/gui/templates/index.html +310 -0
  31. graflag-1.0.0/graflag/models.py +106 -0
  32. graflag-1.0.0/graflag/ssh.py +169 -0
  33. graflag-1.0.0/graflag/utils.py +36 -0
  34. graflag-1.0.0/graflag.egg-info/PKG-INFO +15 -0
  35. graflag-1.0.0/graflag.egg-info/SOURCES.txt +39 -0
  36. graflag-1.0.0/graflag.egg-info/dependency_links.txt +1 -0
  37. graflag-1.0.0/graflag.egg-info/entry_points.txt +2 -0
  38. graflag-1.0.0/graflag.egg-info/requires.txt +5 -0
  39. graflag-1.0.0/graflag.egg-info/top_level.txt +1 -0
  40. graflag-1.0.0/setup.cfg +4 -0
  41. graflag-1.0.0/setup.py +38 -0
graflag-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: graflag
3
+ Version: 1.0.0
4
+ Summary: Distributed benchmarking framework for Graph Anomaly Detection
5
+ Author: gbay7
6
+ Requires-Python: >=3.7
7
+ Requires-Dist: pyyaml>=5.0
8
+ Requires-Dist: docker>=6.0
9
+ Requires-Dist: Flask>=2.0.0
10
+ Requires-Dist: flask-socketio>=5.3.0
11
+ Requires-Dist: python-socketio>=5.9.0
12
+ Dynamic: author
13
+ Dynamic: requires-dist
14
+ Dynamic: requires-python
15
+ Dynamic: summary
@@ -0,0 +1,122 @@
1
+ # GraFlag
2
+
3
+ Distributed benchmarking framework for Graph Anomaly Detection (GAD). Orchestrates experiments on Docker Swarm clusters with NFS-based shared storage.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install graflag
9
+ ```
10
+
11
+ Or from source:
12
+
13
+ ```bash
14
+ git clone https://github.com/lias-laboratory/graflag.git
15
+ cd graflag
16
+ pip install -e .
17
+ ```
18
+
19
+ This installs the `graflag` command (includes CLI, web GUI, and devcluster).
20
+
21
+ ## Related Repositories
22
+
23
+ - [graflag-shared](https://github.com/lias-laboratory/graflag-shared) -- Methods, datasets, and shared libraries (NFS-mounted storage)
24
+ - [Documentation](https://lias-laboratory.github.io/graflag/) -- Full documentation (Sphinx)
25
+
26
+ ## Quick Start
27
+
28
+ ### 1. Set up the shared directory
29
+
30
+ Clone the shared storage repository on your NFS mount:
31
+
32
+ ```bash
33
+ cd /shared # or your NFS mount point
34
+ git clone https://github.com/lias-laboratory/graflag-shared.git .
35
+ git lfs pull # download dataset files
36
+ ```
37
+
38
+ ### 2. Configure
39
+
40
+ Run the interactive setup wizard:
41
+
42
+ ```bash
43
+ graflag setup
44
+ ```
45
+
46
+ This stores configuration in `~/.config/graflag/config.env`. To reconfigure later:
47
+
48
+ ```bash
49
+ graflag setup --reconfigure
50
+ ```
51
+
52
+ Or place a `.env` file in the working directory:
53
+
54
+ ```
55
+ MANAGER_IP=192.168.100.10
56
+ SSH_PORT=22
57
+ SSH_KEY=~/.ssh/id_ed25519
58
+ SHARED_DIR=/shared
59
+ HOSTS_FILE=hosts.yml
60
+ ```
61
+
62
+ ### 3. Run an experiment
63
+
64
+ ```bash
65
+ graflag run -m bond_dominant -d bond_inj_cora --build
66
+ graflag run -m taddy -d uci --params MAX_EPOCH=100 LEARNING_RATE=0.001
67
+ ```
68
+
69
+ ### 4. Evaluate
70
+
71
+ ```bash
72
+ graflag evaluate -e exp__bond_dominant__bond_inj_cora__20260309_120000
73
+ ```
74
+
75
+ ## Commands
76
+
77
+ | Command | Description |
78
+ |---------|-------------|
79
+ | `graflag setup` | Interactive cluster configuration |
80
+ | `graflag run -m METHOD -d DATASET` | Run an experiment |
81
+ | `graflag status` | Show cluster status |
82
+ | `graflag list methods\|datasets\|experiments\|services` | List resources |
83
+ | `graflag logs -e EXP [-f]` | View experiment logs |
84
+ | `graflag stop -e EXP [--rm]` | Stop an experiment |
85
+ | `graflag evaluate -e EXP` | Evaluate experiment results |
86
+ | `graflag copy -s SRC -d DST [-r]` | Copy files to/from remote |
87
+ | `graflag sync [--lib] [--path PATH]` | Sync method or library |
88
+ | `graflag gui [--port PORT]` | Start web dashboard |
89
+ | `graflag devcluster --hosts FILE` | Deploy virtual cluster |
90
+ | `graflag devcluster --down` | Stop virtual cluster |
91
+
92
+ ## Development Cluster
93
+
94
+ For local development without a physical cluster:
95
+
96
+ ```bash
97
+ graflag devcluster --hosts hosts.yml
98
+ graflag setup
99
+ ```
100
+
101
+ ## Dependencies
102
+
103
+ - `pyyaml` -- hosts.yml parsing for cluster setup
104
+ - `docker` -- Docker SDK for Python (service management via SSH tunnel)
105
+ - `flask`, `flask-socketio` -- Web GUI backend
106
+
107
+ ## Module Structure
108
+
109
+ ```
110
+ graflag/
111
+ __init__.py Package exports
112
+ cli.py Argument parsing, command dispatch, output formatting
113
+ core.py GraFlag orchestration class (returns structured data)
114
+ models.py Dataclass models (ClusterInfo, MethodInfo, ExperimentInfo, ...)
115
+ config.py Configuration loading (~/.config/graflag/config.env or .env)
116
+ ssh.py SSH and rsync file operations
117
+ docker_ops.py Docker Swarm operations via Docker SDK (SSH tunnel)
118
+ api.py Python API for GUI integration (wraps core)
119
+ utils.py Shared utility functions
120
+ gui/ Web dashboard subpackage (Flask + Vue.js)
121
+ devcluster/ Virtual cluster subpackage (Docker Compose)
122
+ ```
@@ -0,0 +1,20 @@
1
+ """
2
+ GraFlag - Graph Anomaly Detection Benchmarking Tool
3
+
4
+ A tool for benchmarking Graph Anomaly Detection methods using Docker Swarm
5
+ across multiple nodes with shared NFS storage.
6
+ """
7
+
8
+ from .core import GraFlag, GraFlagError
9
+ from .config import GraflagConfig, CONFIG_FILE
10
+ from .models import (
11
+ ClusterInfo, MethodInfo, DatasetInfo, ExperimentInfo,
12
+ ExperimentResults, EvaluationResults, RunProgress,
13
+ )
14
+
15
+ __version__ = "1.0.0"
16
+ __all__ = [
17
+ "GraFlag", "GraFlagError", "GraflagConfig", "CONFIG_FILE",
18
+ "ClusterInfo", "MethodInfo", "DatasetInfo", "ExperimentInfo",
19
+ "ExperimentResults", "EvaluationResults", "RunProgress",
20
+ ]
@@ -0,0 +1,217 @@
1
+ """
2
+ GraFlag Python API for GUI Integration.
3
+
4
+ Thin wrapper around GraFlag core that provides error-safe access
5
+ and returns structured dataclass objects suitable for GUI/web consumption.
6
+ """
7
+
8
+ from typing import Dict, List, Optional, Any, Callable
9
+ import logging
10
+
11
+ from .core import GraFlag, GraFlagError
12
+ from .config import GraflagConfig
13
+ from .models import (
14
+ ClusterInfo, MethodInfo, DatasetInfo, ExperimentInfo,
15
+ ExperimentResults, EvaluationResults, RunProgress,
16
+ )
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class GraFlagAPI:
22
+ """
23
+ High-level Python API for GraFlag operations.
24
+
25
+ All methods return structured data (dataclasses) and catch exceptions
26
+ to avoid crashing the GUI. Use the core GraFlag class directly for
27
+ CLI-style usage where exceptions should propagate.
28
+
29
+ Usage:
30
+ api = GraFlagAPI(config_file=".env")
31
+
32
+ cluster = api.get_cluster_info()
33
+ methods = api.list_methods()
34
+ experiments = api.list_experiments()
35
+ """
36
+
37
+ def __init__(self, config_file: str = ".env", log_level: int = logging.INFO):
38
+ logging.basicConfig(level=log_level)
39
+ self.core = GraFlag(config_file)
40
+ self.config = self.core.config
41
+
42
+ # ========================================================================
43
+ # Cluster
44
+ # ========================================================================
45
+
46
+ def get_cluster_info(self) -> ClusterInfo:
47
+ """Get cluster status information."""
48
+ return self.core.status()
49
+
50
+ def setup_cluster(self) -> Dict[str, Any]:
51
+ """Setup GraFlag cluster."""
52
+ try:
53
+ self.core.setup()
54
+ return {"success": True, "message": "Cluster setup completed"}
55
+ except Exception as e:
56
+ return {"success": False, "error": str(e)}
57
+
58
+ # ========================================================================
59
+ # Resources
60
+ # ========================================================================
61
+
62
+ def list_methods(self) -> List[MethodInfo]:
63
+ """List available methods."""
64
+ try:
65
+ return self.core.list_methods()
66
+ except Exception as e:
67
+ logger.error(f"Error listing methods: {e}")
68
+ return []
69
+
70
+ def get_method_details(self, method_name: str) -> Optional[MethodInfo]:
71
+ """Get details for a specific method."""
72
+ try:
73
+ methods = self.core.list_methods()
74
+ for m in methods:
75
+ if m.name == method_name:
76
+ return m
77
+ return None
78
+ except Exception as e:
79
+ logger.error(f"Error getting method details: {e}")
80
+ return None
81
+
82
+ def list_datasets(self) -> List[DatasetInfo]:
83
+ """List available datasets."""
84
+ try:
85
+ return self.core.list_datasets()
86
+ except Exception as e:
87
+ logger.error(f"Error listing datasets: {e}")
88
+ return []
89
+
90
+ def list_experiments(self, limit: int = 50) -> List[ExperimentInfo]:
91
+ """List recent experiments."""
92
+ try:
93
+ return self.core.list_experiments(limit=limit)
94
+ except Exception as e:
95
+ logger.error(f"Error listing experiments: {e}")
96
+ return []
97
+
98
+ def get_experiment_details(self, experiment_name: str) -> Optional[ExperimentInfo]:
99
+ """Get details for a specific experiment."""
100
+ try:
101
+ experiments = self.core.list_experiments(limit=500)
102
+ for e in experiments:
103
+ if e.name == experiment_name:
104
+ return e
105
+ return None
106
+ except Exception as e:
107
+ logger.error(f"Error getting experiment details: {e}")
108
+ return None
109
+
110
+ # ========================================================================
111
+ # Run
112
+ # ========================================================================
113
+
114
+ def run(
115
+ self,
116
+ method: str,
117
+ dataset: str,
118
+ tag: str = "latest",
119
+ build: bool = False,
120
+ gpu: bool = True,
121
+ method_params: Optional[Dict[str, Any]] = None,
122
+ on_progress: Optional[Callable[[RunProgress], None]] = None,
123
+ ) -> str:
124
+ """Run an experiment. Returns experiment name."""
125
+ return self.core.run(
126
+ method_name=method,
127
+ dataset=dataset,
128
+ tag=tag,
129
+ build=build,
130
+ gpu=gpu,
131
+ method_params=method_params or {},
132
+ )
133
+
134
+ # ========================================================================
135
+ # Results
136
+ # ========================================================================
137
+
138
+ def get_experiment_results(self, experiment_name: str) -> Optional[ExperimentResults]:
139
+ """Get experiment results."""
140
+ try:
141
+ return self.core.get_experiment_results(experiment_name)
142
+ except Exception as e:
143
+ logger.error(f"Error getting results: {e}")
144
+ return None
145
+
146
+ def get_evaluation_results(self, experiment_name: str) -> Optional[EvaluationResults]:
147
+ """Get evaluation results."""
148
+ try:
149
+ return self.core.get_evaluation_results(experiment_name)
150
+ except Exception as e:
151
+ logger.error(f"Error getting evaluation: {e}")
152
+ return None
153
+
154
+ def evaluate_experiment(self, experiment_name: str) -> bool:
155
+ """Run evaluation on an experiment."""
156
+ self.core.evaluate(experiment_name)
157
+ return True
158
+
159
+ # ========================================================================
160
+ # Services
161
+ # ========================================================================
162
+
163
+ def list_running_services(self) -> List[Dict[str, str]]:
164
+ """List running Docker services."""
165
+ try:
166
+ return self.core.list_services()
167
+ except Exception as e:
168
+ logger.error(f"Error listing services: {e}")
169
+ return []
170
+
171
+ def stop_experiment(self, experiment_name: str) -> bool:
172
+ """Stop a running experiment."""
173
+ try:
174
+ self.core.stop(experiment_name)
175
+ return True
176
+ except Exception as e:
177
+ logger.error(f"Error stopping experiment: {e}")
178
+ return False
179
+
180
+ def delete_experiment(self, experiment_name: str) -> bool:
181
+ """Stop and delete an experiment."""
182
+ try:
183
+ self.core.stop(experiment_name, remove=True)
184
+ return True
185
+ except Exception as e:
186
+ logger.error(f"Error deleting experiment: {e}")
187
+ return False
188
+
189
+ def get_experiment_logs(self, experiment_name: str, tail: int = 100) -> List[str]:
190
+ """Get recent logs for an experiment."""
191
+ try:
192
+ return self.core.get_logs(experiment_name, tail=tail)
193
+ except Exception as e:
194
+ logger.error(f"Error getting logs: {e}")
195
+ return []
196
+
197
+ # ========================================================================
198
+ # File Operations
199
+ # ========================================================================
200
+
201
+ def download_file(self, remote_path: str, local_path: str) -> bool:
202
+ """Download a file from remote shared directory."""
203
+ try:
204
+ self.core.copy_files(remote_path, local_path, recursive=False, from_remote=True)
205
+ return True
206
+ except Exception as e:
207
+ logger.error(f"Error downloading file: {e}")
208
+ return False
209
+
210
+ def download_directory(self, remote_path: str, local_path: str) -> bool:
211
+ """Download a directory from remote shared directory."""
212
+ try:
213
+ self.core.copy_files(remote_path, local_path, recursive=True, from_remote=True)
214
+ return True
215
+ except Exception as e:
216
+ logger.error(f"Error downloading directory: {e}")
217
+ return False
@@ -0,0 +1,321 @@
1
+ """Command Line Interface for GraFlag."""
2
+
3
+ import os
4
+ import sys
5
+ import json
6
+ import argparse
7
+ import logging
8
+ import traceback
9
+ from pathlib import Path
10
+
11
+ from .core import GraFlag, GraFlagError
12
+ from .config import get_config_path, init_config
13
+
14
+ logging.basicConfig(
15
+ level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
16
+ )
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def main():
21
+ """Main CLI interface."""
22
+ parser = argparse.ArgumentParser(
23
+ description="GraFlag - Graph Anomaly Detection Benchmarking Tool",
24
+ formatter_class=argparse.RawDescriptionHelpFormatter,
25
+ epilog="""\
26
+ Examples:
27
+ graflag setup # Setup cluster
28
+ graflag setup --reconfigure # Re-run config wizard
29
+ graflag run --method Dummy --dataset Cora # Run experiment with GPU
30
+ graflag run -m taddy -d uci --build --params MAX_EPOCH=100 LEARNING_RATE=0.001
31
+ graflag run -m DeepWalk -d CiteSeer --no-gpu # Run without GPU
32
+ graflag run --from-config ./experiments/exp__method__dataset__timestamp/service_config.json
33
+ graflag status # Show cluster status
34
+ graflag list methods # List available methods
35
+ graflag list services # List running services
36
+ graflag logs -e exp__dummy__cora__20250924_161245 # Show logs
37
+ graflag logs -e exp__dummy__cora__20250924_161245 -f # Follow logs
38
+ graflag stop -e exp__dummy__cora__20250924_161245 # Stop experiment
39
+ graflag evaluate -e exp__generaldyg__btc_alpha__20251211_120000 # Evaluate
40
+ graflag copy -s ./data -d datasets -r # Copy to remote
41
+ graflag copy --from-remote -s experiments/exp -d ./local # Copy from remote
42
+ graflag sync # Sync current method dir
43
+ graflag sync --lib --path ./my-lib/ # Sync a shared library
44
+ graflag gui # Start web dashboard
45
+ graflag gui --port 8080 --debug # GUI on custom port
46
+ graflag devcluster --hosts hosts.yml # Deploy virtual cluster
47
+ graflag devcluster --hosts hosts.yml --pubkey ~/.ssh/id_rsa.pub
48
+ graflag devcluster --down # Stop and remove cluster
49
+ """,
50
+ )
51
+
52
+ parser.add_argument(
53
+ "command",
54
+ choices=["setup", "run", "status", "list", "copy", "logs", "stop", "evaluate", "sync", "gui", "devcluster"],
55
+ help="Command to execute",
56
+ )
57
+ parser.add_argument(
58
+ "subcommand", nargs="?",
59
+ choices=["methods", "datasets", "experiments", "services"],
60
+ help="Subcommand for list command",
61
+ )
62
+ parser.add_argument("--method", "-m", help="Method name for run")
63
+ parser.add_argument("--dataset", "-d", help="Dataset name for run")
64
+ parser.add_argument("--tag", "-t", default="latest", help="Docker image tag (default: latest)")
65
+ parser.add_argument("--build", "-b", action="store_true", help="Build image before running")
66
+ parser.add_argument("--config", "-c", default=".env", help="Configuration file (default: .env)")
67
+ parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging")
68
+ parser.add_argument("--source", "-s", nargs='+', help="Source path(s) for copy command")
69
+ parser.add_argument("--dest", help="Destination path for copy command")
70
+ parser.add_argument("--recursive", "-r", action="store_true", help="Copy directories recursively")
71
+ parser.add_argument("--from-remote", action="store_true", help="Copy from remote to local")
72
+ parser.add_argument("--experiment", "-e", help="Experiment name for logs/stop commands")
73
+ parser.add_argument("--follow", "-f", action="store_true", help="Follow log output")
74
+ parser.add_argument("--rm", action="store_true", help="Also delete experiment directory on stop")
75
+ parser.add_argument("--tee", help="Save logs to file while displaying")
76
+ parser.add_argument("--gpu", "-g", action="store_true", default=True, help="Enable GPU (default: True)")
77
+ parser.add_argument("--no-gpu", action="store_false", dest="gpu", help="Disable GPU")
78
+ parser.add_argument("--params", "-p", nargs='+', metavar="KEY=VALUE", help="Method parameters")
79
+ parser.add_argument(
80
+ "--from-config", metavar="CONFIG_FILE",
81
+ help="Load method/dataset/params from a config file",
82
+ )
83
+ parser.add_argument("--lib", action="store_true", help="Sync as a shared library")
84
+ parser.add_argument("--path", default=None, help="Local path for sync command")
85
+ # GUI args
86
+ parser.add_argument("--host", default="0.0.0.0", help="GUI server host (default: 0.0.0.0)")
87
+ parser.add_argument("--port", default=5000, type=int, help="GUI server port (default: 5000)")
88
+ parser.add_argument("--debug", action="store_true", help="Enable GUI debug mode")
89
+ # Devcluster args
90
+ parser.add_argument("--hosts", default=None, help="Path to hosts.yml for devcluster")
91
+ parser.add_argument("--pubkey", default=None, help="Path to SSH public key for devcluster")
92
+ parser.add_argument("--down", action="store_true", help="Stop and remove devcluster")
93
+ parser.add_argument("--reconfigure", action="store_true", help="Re-run configuration wizard for setup")
94
+
95
+ args = parser.parse_args()
96
+
97
+ if args.verbose:
98
+ logging.getLogger().setLevel(logging.DEBUG)
99
+
100
+ try:
101
+ # GUI: start web dashboard (doesn't need GraFlag instance directly)
102
+ if args.command == "gui":
103
+ from .gui.server import serve
104
+ serve(args.config, args.host, args.port, args.debug)
105
+ return
106
+
107
+ # Devcluster: deploy or tear down virtual cluster
108
+ if args.command == "devcluster":
109
+ if not args.hosts and not args.down:
110
+ parser.error("devcluster requires --hosts <path-to-hosts.yml> or --down")
111
+ from .devcluster.cli import main as devcluster_main
112
+ devcluster_main(args.hosts, args.pubkey, args.down)
113
+ return
114
+
115
+ # Setup: create or update config interactively
116
+ if args.command == "setup":
117
+ config_path = get_config_path(args.config)
118
+ if not config_path.exists() or args.reconfigure:
119
+ init_config()
120
+
121
+ gf = GraFlag(config_file=args.config)
122
+
123
+ if args.command == "setup":
124
+ gf.setup()
125
+ # Show status after setup
126
+ _print_status(gf.status())
127
+
128
+ elif args.command == "run":
129
+ method, dataset, method_params = _parse_run_args(args, parser)
130
+ gf.run(method, dataset, args.tag, args.build, args.gpu, method_params)
131
+
132
+ elif args.command == "status":
133
+ _print_status(gf.status())
134
+
135
+ elif args.command == "list":
136
+ if args.subcommand == "methods":
137
+ _print_methods(gf.list_methods())
138
+ elif args.subcommand == "datasets":
139
+ _print_datasets(gf.list_datasets())
140
+ elif args.subcommand == "experiments":
141
+ _print_experiments(gf.list_experiments())
142
+ elif args.subcommand == "services":
143
+ _print_services(gf.list_services())
144
+ else:
145
+ parser.error("list command requires subcommand: methods, datasets, experiments, or services")
146
+
147
+ elif args.command == "copy":
148
+ if not args.source or not args.dest:
149
+ parser.error("copy command requires --source and --dest")
150
+ gf.copy_files(args.source, args.dest, args.recursive, args.from_remote)
151
+
152
+ elif args.command == "logs":
153
+ if not args.experiment:
154
+ parser.error("logs command requires --experiment")
155
+ if args.follow:
156
+ gf.follow_logs(args.experiment, args.tee)
157
+ else:
158
+ gf.show_logs(args.experiment, args.tee)
159
+
160
+ elif args.command == "stop":
161
+ if not args.experiment:
162
+ parser.error("stop command requires --experiment")
163
+ gf.stop(args.experiment, remove=args.rm)
164
+
165
+ elif args.command == "evaluate":
166
+ if not args.experiment:
167
+ parser.error("evaluate command requires --experiment")
168
+ gf.evaluate(args.experiment)
169
+
170
+ elif args.command == "sync":
171
+ local_path = args.path or os.getcwd()
172
+ gf.sync(local_path, is_lib=args.lib)
173
+
174
+ except GraFlagError as e:
175
+ logger.error(str(e))
176
+ sys.exit(1)
177
+ except KeyboardInterrupt:
178
+ logger.info("Interrupted by user")
179
+ sys.exit(0)
180
+ except Exception as e:
181
+ logger.error(f"Unexpected error: {e}")
182
+ if args.verbose:
183
+ traceback.print_exc()
184
+ sys.exit(1)
185
+
186
+
187
+ # ============================================================================
188
+ # Output Formatting
189
+ # ============================================================================
190
+
191
+ def _parse_run_args(args, parser):
192
+ """Parse run arguments from CLI args."""
193
+ method = args.method
194
+ dataset = args.dataset
195
+ method_params = {}
196
+
197
+ if args.from_config:
198
+ config_path = Path(args.from_config)
199
+ if not config_path.exists():
200
+ parser.error(f"Config file not found: {args.from_config}")
201
+
202
+ try:
203
+ with open(config_path, 'r') as f:
204
+ config = json.load(f)
205
+ except json.JSONDecodeError as e:
206
+ parser.error(f"Invalid JSON in config file: {e}")
207
+
208
+ if not method:
209
+ method = config.get('method_name')
210
+ if not dataset:
211
+ dataset = config.get('dataset')
212
+
213
+ env_contents = config.get('env_contents', {})
214
+ for key, value in env_contents.items():
215
+ if key.startswith('_'):
216
+ method_params[key[1:]] = str(value)
217
+
218
+ if args.params:
219
+ for param in args.params:
220
+ if '=' not in param:
221
+ parser.error(f"Invalid parameter format: {param}. Use KEY=VALUE.")
222
+ key, value = param.split('=', 1)
223
+ method_params[key] = value
224
+
225
+ if not method or not dataset:
226
+ parser.error("run requires --method and --dataset (or --from-config)")
227
+
228
+ return method, dataset, method_params
229
+
230
+
231
+ def _print_status(cluster_info):
232
+ """Format and print cluster status."""
233
+ if cluster_info.error:
234
+ print(f"[ERROR] {cluster_info.error}")
235
+ return
236
+
237
+ print(f"\n[INFO] Manager: {cluster_info.manager_ip}")
238
+ print(f"[INFO] Swarm: {'active' if cluster_info.swarm_initialized else 'inactive'}")
239
+
240
+ if cluster_info.worker_nodes:
241
+ print("\n[INFO] Nodes:")
242
+ for node in cluster_info.worker_nodes:
243
+ role = "manager" if node.get('is_manager') else "worker"
244
+ print(f" - {node['hostname']}: {node['status']} ({role}, {node['availability']})")
245
+
246
+ if cluster_info.services:
247
+ print(f"\n[INFO] Running Services:")
248
+ print(f" {'NAME':<50} {'REPLICAS':<15} {'IMAGE':<30}")
249
+ print(" " + "-" * 95)
250
+ for svc in cluster_info.services:
251
+ name = svc['name'][:49]
252
+ replicas = svc.get('replicas', '')[:14]
253
+ image = svc.get('image', '')[:29]
254
+ print(f" {name:<50} {replicas:<15} {image:<30}")
255
+ else:
256
+ print("\n[INFO] Running Services: None")
257
+
258
+ print(f"\n[INFO] Shared Directory: {cluster_info.shared_dir}")
259
+ if cluster_info.shared_contents:
260
+ print(" Contents:")
261
+ for item in cluster_info.shared_contents:
262
+ print(f" - {item}")
263
+
264
+
265
+ def _print_methods(methods):
266
+ """Format and print method list."""
267
+ if not methods:
268
+ print("[INFO] No methods found")
269
+ return
270
+
271
+ print("[INFO] Available Methods:")
272
+ for m in methods:
273
+ print(f" - {m.name} (Supports: {m.supported_data})")
274
+
275
+
276
+ def _print_datasets(datasets):
277
+ """Format and print dataset list."""
278
+ if not datasets:
279
+ print("[INFO] No datasets found")
280
+ return
281
+
282
+ print("[INFO] Available Datasets:")
283
+ for d in datasets:
284
+ size_str = f" ({d.size_mb:.1f} MB, {d.file_count} files)" if d.size_mb > 0 else ""
285
+ print(f" - {d.name}{size_str}")
286
+
287
+
288
+ def _print_experiments(experiments):
289
+ """Format and print experiment list."""
290
+ if not experiments:
291
+ print("[INFO] No experiments found")
292
+ return
293
+
294
+ print("[INFO] Recent Experiments:")
295
+ for e in experiments:
296
+ tags = f"[{e.status}]"
297
+ if e.has_results:
298
+ tags += " [results]"
299
+ if e.has_evaluation:
300
+ tags += " [eval]"
301
+ print(f" - {e.name} {tags}")
302
+
303
+
304
+ def _print_services(services):
305
+ """Format and print running services."""
306
+ if not services:
307
+ print("\n[INFO] Running Services: None")
308
+ return
309
+
310
+ print("\n[INFO] Running Services:")
311
+ print(f" {'NAME':<50} {'REPLICAS':<15} {'IMAGE':<30}")
312
+ print(" " + "-" * 95)
313
+ for svc in services:
314
+ name = svc['name'][:49]
315
+ replicas = str(svc.get('replicas', ''))[:14]
316
+ image = svc.get('image', '')[:29]
317
+ print(f" {name:<50} {replicas:<15} {image:<30}")
318
+
319
+
320
+ if __name__ == "__main__":
321
+ main()