graflag 1.0.0__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.
- graflag/__init__.py +20 -0
- graflag/api.py +217 -0
- graflag/cli.py +321 -0
- graflag/config.py +128 -0
- graflag/core.py +735 -0
- graflag/devcluster/__init__.py +1 -0
- graflag/devcluster/cli.py +75 -0
- graflag/devcluster/deploy.sh +112 -0
- graflag/devcluster/docker-compose.yml +122 -0
- graflag/devcluster/hosts.yml +8 -0
- graflag/devcluster/manager/Dockerfile.manager +107 -0
- graflag/devcluster/manager/nvidia-setup.sh +174 -0
- graflag/devcluster/manager/registry-setup.sh +30 -0
- graflag/devcluster/manager/setup-nfs.sh +87 -0
- graflag/devcluster/worker/Dockerfile.worker +101 -0
- graflag/devcluster/worker/nvidia-setup.sh +171 -0
- graflag/devcluster/worker/registry-setup.sh +30 -0
- graflag/devcluster/worker/setup-nfs.sh +74 -0
- graflag/docker_ops.py +670 -0
- graflag/gui/__init__.py +1 -0
- graflag/gui/server.py +490 -0
- graflag/gui/static/css/style.css +880 -0
- graflag/gui/static/js/app.js +656 -0
- graflag/gui/static/js/components/ClusterStatus.js +58 -0
- graflag/gui/static/js/components/DataTable.js +124 -0
- graflag/gui/static/js/components/ExperimentModal.js +21 -0
- graflag/gui/static/js/components/RunForm.js +338 -0
- graflag/gui/templates/index.html +310 -0
- graflag/models.py +106 -0
- graflag/ssh.py +169 -0
- graflag/utils.py +36 -0
- graflag-1.0.0.dist-info/METADATA +15 -0
- graflag-1.0.0.dist-info/RECORD +36 -0
- graflag-1.0.0.dist-info/WHEEL +5 -0
- graflag-1.0.0.dist-info/entry_points.txt +2 -0
- graflag-1.0.0.dist-info/top_level.txt +1 -0
graflag/__init__.py
ADDED
|
@@ -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
|
+
]
|
graflag/api.py
ADDED
|
@@ -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
|
graflag/cli.py
ADDED
|
@@ -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()
|
graflag/config.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Configuration management for GraFlag."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, Optional
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
CONFIG_DIR = Path.home() / ".config" / "graflag"
|
|
11
|
+
CONFIG_FILE = CONFIG_DIR / "config.env"
|
|
12
|
+
|
|
13
|
+
DEFAULTS = {
|
|
14
|
+
"SSH_PORT": "22",
|
|
15
|
+
"SHARED_DIR": "/shared",
|
|
16
|
+
"NFS_PORT": "2049",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
PROMPTS = [
|
|
20
|
+
("MANAGER_IP", "Manager IP address", None),
|
|
21
|
+
("SSH_PORT", "SSH port", "22"),
|
|
22
|
+
("SSH_KEY", "SSH private key path", "~/.ssh/id_ed25519"),
|
|
23
|
+
("SHARED_DIR", "Remote shared directory", "/shared"),
|
|
24
|
+
("HOSTS_FILE", "Hosts file (hosts.yml) path", "hosts.yml"),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_config_path(override: Optional[str] = None) -> Path:
|
|
29
|
+
"""Resolve config file path. Checks override, then cwd .env, then standard location."""
|
|
30
|
+
if override and override != ".env":
|
|
31
|
+
return Path(override)
|
|
32
|
+
cwd_env = Path.cwd() / ".env"
|
|
33
|
+
if cwd_env.exists():
|
|
34
|
+
return cwd_env
|
|
35
|
+
return CONFIG_FILE
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def init_config() -> Path:
|
|
39
|
+
"""Interactively create configuration file."""
|
|
40
|
+
print("GraFlag configuration")
|
|
41
|
+
print(f"Config will be saved to: {CONFIG_FILE}\n")
|
|
42
|
+
|
|
43
|
+
values = {}
|
|
44
|
+
for key, prompt, default in PROMPTS:
|
|
45
|
+
if default:
|
|
46
|
+
raw = input(f" {prompt} [{default}]: ").strip()
|
|
47
|
+
values[key] = raw if raw else default
|
|
48
|
+
else:
|
|
49
|
+
while True:
|
|
50
|
+
raw = input(f" {prompt}: ").strip()
|
|
51
|
+
if raw:
|
|
52
|
+
values[key] = raw
|
|
53
|
+
break
|
|
54
|
+
print(f" {key} is required.")
|
|
55
|
+
|
|
56
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
with open(CONFIG_FILE, "w") as f:
|
|
58
|
+
f.write("# GraFlag Configuration\n")
|
|
59
|
+
for key, _, _ in PROMPTS:
|
|
60
|
+
f.write(f"{key}={values[key]}\n")
|
|
61
|
+
|
|
62
|
+
print(f"\n[OK] Configuration saved to {CONFIG_FILE}")
|
|
63
|
+
return CONFIG_FILE
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class GraflagConfig:
|
|
67
|
+
"""Handle configuration loading and validation for GraFlag."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, config_file: str = ".env"):
|
|
70
|
+
"""Initialize configuration from file."""
|
|
71
|
+
self.config_path = get_config_path(config_file)
|
|
72
|
+
self.config = self._load_config()
|
|
73
|
+
self._validate_required_config()
|
|
74
|
+
|
|
75
|
+
def _load_config(self) -> Dict[str, str]:
|
|
76
|
+
"""Load configuration from .env file."""
|
|
77
|
+
config = dict(DEFAULTS)
|
|
78
|
+
|
|
79
|
+
if not self.config_path.exists():
|
|
80
|
+
return config
|
|
81
|
+
|
|
82
|
+
with open(self.config_path, "r") as f:
|
|
83
|
+
for line in f:
|
|
84
|
+
line = line.strip()
|
|
85
|
+
if line and not line.startswith("#") and "=" in line:
|
|
86
|
+
key, value = line.split("=", 1)
|
|
87
|
+
config[key.strip()] = value.strip()
|
|
88
|
+
|
|
89
|
+
return config
|
|
90
|
+
|
|
91
|
+
def _validate_required_config(self):
|
|
92
|
+
"""Validate that required configuration is present."""
|
|
93
|
+
required_keys = ["MANAGER_IP"]
|
|
94
|
+
missing_keys = [key for key in required_keys if not self.get(key)]
|
|
95
|
+
|
|
96
|
+
if missing_keys:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"Missing required configuration: {', '.join(missing_keys)}. "
|
|
99
|
+
f"Run 'graflag setup' to configure."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def get(self, key: str, default: Optional[str] = None) -> Optional[str]:
|
|
103
|
+
"""Get configuration value."""
|
|
104
|
+
return self.config.get(key, default)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def remote_shared_dir(self) -> str:
|
|
108
|
+
return self.get("SHARED_DIR", "/shared")
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def manager_ip(self) -> str:
|
|
112
|
+
return self.get("MANAGER_IP")
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def ssh_port(self) -> str:
|
|
116
|
+
return self.get("SSH_PORT", "22")
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def ssh_key(self) -> Optional[str]:
|
|
120
|
+
return self.get("SSH_KEY")
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def nfs_port(self) -> str:
|
|
124
|
+
return self.get("NFS_PORT", "2049")
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def hosts_file(self) -> Optional[str]:
|
|
128
|
+
return self.get("HOSTS_FILE", "hosts.yml")
|