hecaton 0.1.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.
hecaton-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: hecaton
3
+ Version: 0.1.0
4
+ Summary: Hecaton distributed compute framework
5
+ Author-email: Just1duc <aworld068@gmail.com>
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: fastapi
9
+ Requires-Dist: uvicorn
10
+ Requires-Dist: platformdirs
11
+ Requires-Dist: typer
12
+ Requires-Dist: prompt_toolkit
13
+ Requires-Dist: click
14
+ Requires-Dist: docker
15
+
16
+ # hecaton
17
+ A solution for self-hosted GPU access from a source that isn't deployed
@@ -0,0 +1,2 @@
1
+ # hecaton
2
+ A solution for self-hosted GPU access from a source that isn't deployed
File without changes
File without changes
@@ -0,0 +1,17 @@
1
+ import argparse
2
+
3
+ parser = argparse.ArgumentParser(
4
+ prog="Hecaton Client",
5
+ description="A simple cli to use the Hecaton Server"
6
+ )
7
+ # The login is done in CLI
8
+ # Saved in .cache/hecaton
9
+ # The logout should also be available
10
+ # The usage of a server is done in the cli
11
+
12
+ # CLI
13
+ # connect [SERVER]
14
+ # new_job, new [filepath] -> job id
15
+ # status_job, status [ID] -> (STATUS, results|None)
16
+ # get_jobs, get -> Return all running jobs (from the server)
17
+ # delete_job, del -> Put a job in failed and free a GPU
@@ -0,0 +1,257 @@
1
+ import shlex, typer, os
2
+ from prompt_toolkit import PromptSession
3
+ from prompt_toolkit.formatted_text import ANSI
4
+ from hecaton.client.managers import ServerManager, ImageManager, JobManager, apps
5
+ from prompt_toolkit.history import InMemoryHistory
6
+ from prompt_toolkit.completion import Completer, Completion
7
+ from typer.main import get_command
8
+ from typing import Iterable, List, Tuple, Optional
9
+ import click
10
+ import importlib
11
+
12
+ app = typer.Typer()
13
+
14
+ _original_echo = typer.echo
15
+
16
+ def _indented_echo(message="", indent=4, **kwargs):
17
+ prefix = " " * indent
18
+ msg = "\n".join(prefix + line for line in str(message).splitlines())
19
+ _original_echo(msg, **kwargs)
20
+
21
+
22
+ shared_context = {
23
+ "server_mgr" : ServerManager(),
24
+ "job_mgr" : JobManager(),
25
+ "image_mgr" : ImageManager()
26
+ }
27
+ def _split_args(text: str):
28
+ try:
29
+ return click.parser.split_arg_string(text)
30
+ except Exception:
31
+ return text.split()
32
+
33
+ def _resolve_chain(root_cmd, root_ctx, args):
34
+ cmd, ctx, rest = root_cmd, root_ctx, list(args)
35
+ # print("start rest", rest)
36
+ while isinstance(cmd, click.MultiCommand) and rest:
37
+ name, sub_cmd, rest2 = cmd.resolve_command(ctx, rest)
38
+ if not sub_cmd:
39
+ break
40
+ ctx = sub_cmd.make_context(
41
+ info_name=name,
42
+ args=rest2,
43
+ parent=ctx,
44
+ resilient_parsing=True,
45
+ obj=ctx.obj,
46
+ )
47
+ cmd, rest = sub_cmd, rest[1:]
48
+ # print("rest end", rest)
49
+ return cmd, ctx, rest
50
+
51
+ def _match_option(token: str, options: List[click.Option]) -> Optional[click.Option]:
52
+ for opt in options:
53
+ if token in opt.opts or token in opt.secondary_opts:
54
+ return opt
55
+ return None
56
+
57
+ def _consume_tokens_for_option(opt: click.Option, tokens: List[str], i: int) -> int:
58
+
59
+ t = tokens[i]
60
+
61
+ if "=" in t and t.startswith("-"):
62
+ return i + 1
63
+
64
+ if opt.nargs == 0:
65
+ return i + 1
66
+
67
+ return min(i + 1 + opt.nargs, len(tokens))
68
+
69
+ def determine_active_param(
70
+ leaf_cmd: click.Command,
71
+ args_after_leaf: List[str],
72
+ completing_option_name: bool,
73
+ ) -> Tuple[str, Optional[click.Parameter], int]:
74
+ """
75
+ Returns (kind, param, pos_index)
76
+ kind ∈ {"option_name", "option_value", "positional", "none"}
77
+ param: the active click.Option or click.Argument (or None)
78
+ pos_index: index of active positional (or -1)
79
+ """
80
+ params = leaf_cmd.params
81
+ opt_params = [p for p in params if isinstance(p, click.Option)]
82
+ pos_params = [p for p in params if isinstance(p, click.Argument)]
83
+
84
+ if completing_option_name:
85
+ return ("option_name", None, -1)
86
+
87
+ i = 0
88
+ pos_i = 0
89
+ while i < len(args_after_leaf):
90
+ tok = args_after_leaf[i]
91
+ # Option with attached value: --opt=value → treat as fully consumed
92
+ if tok.startswith("--") and "=" in tok:
93
+ i += 1
94
+ continue
95
+ # Option name?
96
+ opt = _match_option(tok, opt_params) if tok.startswith("-") else None
97
+ if opt is not None:
98
+ i = _consume_tokens_for_option(opt, args_after_leaf, i)
99
+ continue
100
+ # Otherwise this token belongs to current positional
101
+ if pos_i < len(pos_params):
102
+ arg = pos_params[pos_i]
103
+ # default nargs is 1; Typer uses 1 unless you set it
104
+ nargs = getattr(arg, "nargs", 1)
105
+ if nargs in (1, None):
106
+ pos_i += 1
107
+ i += 1
108
+ elif nargs == -1: # variadic: all remaining go here
109
+ # Since we’re *before* the incomplete token, all remaining are consumed
110
+ # and we remain on this positional
111
+ i = len(args_after_leaf)
112
+ else: # fixed >1
113
+ # consume up to nargs tokens into this positional
114
+ take = min(nargs, len(args_after_leaf) - i)
115
+ pos_i += 1
116
+ i += take
117
+ else:
118
+ # extra tokens; move on
119
+ i += 1
120
+
121
+ if args_after_leaf:
122
+ last = args_after_leaf[-1]
123
+ last_opt = _match_option(last, opt_params)
124
+ if last_opt is not None and last_opt.nargs != 0:
125
+ return ("option_value", last_opt, -1)
126
+
127
+ if pos_i < len(pos_params):
128
+ return ("positional", pos_params[pos_i], pos_i)
129
+
130
+ return ("none", None, -1)
131
+
132
+ class TyperCompleter(Completer):
133
+ def __init__(self, typer_app, make_root_ctx):
134
+ self.root_cmd = get_command(typer_app)
135
+ self.make_root_ctx = make_root_ctx
136
+
137
+ def get_completions(self, document, complete_event):
138
+ text = document.text_before_cursor
139
+ args = _split_args(text)
140
+ incomplete = "" if text.endswith((" ", "\t")) else (args.pop() if args else "")
141
+ # print(args)
142
+
143
+ root_ctx = self.make_root_ctx()
144
+ leaf_cmd, leaf_ctx, rest = _resolve_chain(self.root_cmd, root_ctx, args)
145
+
146
+ args_after_leaf = rest
147
+ # IMPORTANT: call on the LEAF command with (ctx, incomplete)
148
+ # print("Command name", leaf_cmd.name)
149
+ items = list(leaf_cmd.shell_complete(leaf_ctx, incomplete))
150
+
151
+ if not items:
152
+ # Decide what we’re completing
153
+ kind, param, pos_index = determine_active_param(
154
+ leaf_cmd,
155
+ args_after_leaf=args_after_leaf,
156
+ completing_option_name=incomplete.startswith("-"),
157
+ )
158
+
159
+ if kind == "option_name":
160
+ # suggest option switches
161
+ for p in (pp for pp in leaf_cmd.params if isinstance(pp, click.Option)):
162
+ for sw in (*p.opts, *p.secondary_opts):
163
+ if sw.startswith(incomplete):
164
+ items.append(click.shell_completion.CompletionItem(sw))
165
+
166
+ elif kind == "option_value" and param is not None:
167
+ # OPTION value completion → param or its type
168
+ if getattr(param, "shell_complete", None):
169
+ items = list(param.shell_complete(leaf_ctx, incomplete))
170
+ elif getattr(param.type, "shell_complete", None):
171
+ items = list(param.type.shell_complete(leaf_ctx, param, incomplete))
172
+
173
+ elif kind == "positional" and param is not None:
174
+ # POSITIONAL completion → param or its type
175
+ if getattr(param, "shell_complete", None):
176
+ items = list(param.shell_complete(leaf_ctx, incomplete))
177
+ elif getattr(param.type, "shell_complete", None):
178
+ items = list(param.type.shell_complete(leaf_ctx, param, incomplete))
179
+
180
+ # print("DBG", {
181
+ # "args_after_leaf": args_after_leaf,
182
+ # "kind": kind,
183
+ # "param": getattr(param, "name", None),
184
+ # "pos_index": pos_index,
185
+ # "incomplete": incomplete,
186
+ # })
187
+
188
+ # yield items
189
+ start = -len(incomplete)
190
+ for it in items:
191
+ yield Completion(it.value, start_position=start,
192
+ display=it.value, display_meta=(it.help or ""))
193
+
194
+ @app.callback()
195
+ def main(ctx: typer.Context):
196
+ ctx.obj = shared_context
197
+
198
+ app.add_typer(apps.job_app, name="job")
199
+ app.add_typer(apps.image_app, name="image")
200
+ app.add_typer(apps.server_app, name="server")
201
+
202
+ @app.command("ls")
203
+ def list_files():
204
+ print(*[" " + f for f in os.listdir(".")], sep="\n")
205
+
206
+ @app.command("cd")
207
+ def change_dir(dir):
208
+ os.chdir(dir)
209
+
210
+ @app.command("help")
211
+ def greet():
212
+ with importlib.resources.open_text("hecaton", "../help.txt") as f:
213
+ typer.echo(f.read())
214
+
215
+ @app.command()
216
+ def unknown():
217
+ typer.echo(f"Unknown command")
218
+
219
+ def run_shell():
220
+ logo = importlib.resources.open_text("hecaton", "../logo_hecaton.txt").read()
221
+ # logo = open("logo_hecaton.txt", encoding="utf-8").read()
222
+ typer.echo(logo)
223
+
224
+ root_click_cmd = get_command(app)
225
+ def make_root_ctx():
226
+ return root_click_cmd.make_context(
227
+ info_name="", args=[], resilient_parsing=True, obj=shared_context
228
+ )
229
+ completer = TyperCompleter(app, make_root_ctx)
230
+
231
+ typer.echo = _indented_echo
232
+ session = PromptSession(
233
+ completer=completer,
234
+ complete_while_typing=True
235
+ )
236
+ while True:
237
+ try:
238
+ line = session.prompt(ANSI(f" \x1b[36;1mhecaton\x1b[0m ({shared_context['server_mgr'].selected_server or 'Not connected'}) \x1b[35m›\x1b[0m "))
239
+ except (EOFError, KeyboardInterrupt):
240
+ break
241
+ if not line.strip():
242
+ continue
243
+ if line.strip() in {"quit", "exit"}:
244
+ break
245
+ try:
246
+ # Run Typer/Click without sys.exit()
247
+ app(standalone_mode=False, args=shlex.split(line))
248
+ except SystemExit:
249
+ pass
250
+ except Exception as e:
251
+ typer.echo(f"error: {e}")
252
+
253
+ def main():
254
+ run_shell()
255
+
256
+ if __name__ == "__main__":
257
+ main()
@@ -0,0 +1,11 @@
1
+ from hecaton.client.managers.image import ImageManager, image_app
2
+ from hecaton.client.managers.server import ServerManager, server_app
3
+ from hecaton.client.managers.job import JobManager, job_app
4
+
5
+ class Apps:
6
+
7
+ image_app = image_app
8
+ server_app = server_app
9
+ job_app = job_app
10
+
11
+ apps = Apps()
@@ -0,0 +1,131 @@
1
+ # File that contain the class that communicates with the hecaton's server
2
+ import requests
3
+ from typing import Optional, List, Tuple
4
+
5
+ class HecatonServer:
6
+
7
+ str_to_method = {
8
+ "GET" : requests.get,
9
+ "POST": requests.post,
10
+ "PUT" : requests.put,
11
+ "DELETE" : requests.delete
12
+ }
13
+
14
+ def call_endpoint(
15
+ ip : str,
16
+ secret : str,
17
+ method : str,
18
+ endpoint : str,
19
+ payload : dict | None = None
20
+ ):
21
+ ip = ip if ip.startswith('http') else f'http://{ip}'
22
+ result = HecatonServer.str_to_method[method](f"{ip}{endpoint}",
23
+ headers = { "Authorization" : secret },
24
+ **({"json" : payload} if method != "GET" else {})
25
+ )
26
+ return result
27
+
28
+ def list_jobs(
29
+ ip : str,
30
+ secret : str
31
+ ):
32
+ results = HecatonServer.call_endpoint(
33
+ ip=ip,
34
+ secret=secret,
35
+ method="GET",
36
+ endpoint="/jobs"
37
+ )
38
+ if results.ok:
39
+ return results.json()
40
+ return results.json()["detail"]
41
+
42
+ def list_images(
43
+ ip : str,
44
+ secret : str
45
+ ):
46
+ results = HecatonServer.call_endpoint(
47
+ ip=ip,
48
+ secret=secret,
49
+ method="GET",
50
+ endpoint="/images"
51
+ )
52
+ if results.ok:
53
+ return results.json()
54
+ return results.json()["detail"]
55
+
56
+ def new_job(
57
+ ip : str,
58
+ secret : str,
59
+ file_path : str,
60
+ image : str
61
+ ):
62
+ file_content = open(file_path, "r").read()
63
+
64
+ results = HecatonServer.call_endpoint(
65
+ ip=ip,
66
+ secret=secret,
67
+ method="POST",
68
+ endpoint="/jobs/new",
69
+ payload={
70
+ "payload" : file_content,
71
+ "image" : image
72
+ }
73
+ )
74
+ if results.ok:
75
+ return results.json()["job_id"]
76
+ return results.json()["detail"]
77
+
78
+ def update_image(
79
+ ip,
80
+ secret,
81
+ image : str,
82
+ env : List[Tuple[str, str]] | None = None,
83
+ description : Optional[str] = None
84
+ ):
85
+ results = HecatonServer.call_endpoint(
86
+ ip=ip,
87
+ secret=secret,
88
+ method="POST",
89
+ endpoint="/images/update",
90
+ payload={
91
+ "image_name" : image,
92
+ **({"env" : [{"key" : var[0], "value" : var[1]} for var in env]} if env else {}),
93
+ **({"description" : description} if description else {})
94
+ }
95
+ )
96
+ if results.ok:
97
+ return results.json()["message"]
98
+ return results.json()["detail"]
99
+
100
+ def new_image(
101
+ ip : str,
102
+ secret : str,
103
+ image : str
104
+ ):
105
+ results = HecatonServer.call_endpoint(
106
+ ip=ip,
107
+ secret=secret,
108
+ method="POST",
109
+ endpoint="/images/new",
110
+ payload={
111
+ "image_name" : image,
112
+ }
113
+ )
114
+ if results.ok:
115
+ return results.json()["message"]
116
+ return results.json()["detail"]
117
+
118
+ def get_job(
119
+ ip : str,
120
+ secret : str,
121
+ jid : str
122
+ ):
123
+ results = HecatonServer.call_endpoint(
124
+ ip=ip,
125
+ secret=secret,
126
+ method="GET",
127
+ endpoint=f"/jobs/{jid}"
128
+ )
129
+ if results.ok:
130
+ return results.json()
131
+ return results.json()["detail"]
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from typing import List
6
+ from pathlib import Path
7
+ from datetime import datetime
8
+ from filelock import FileLock
9
+ from contextlib import contextmanager
10
+ from platformdirs import user_data_path
11
+ from pydantic import BaseModel, EmailStr, Field
12
+
13
+ SCHEMA_VERSION = 1
14
+
15
+ class ServerInfo(BaseModel):
16
+ ip: str
17
+ name: str
18
+ secret: str
19
+ created_at: datetime = Field(default_factory=datetime.utcnow)
20
+
21
+ class Database(BaseModel):
22
+ version: int = SCHEMA_VERSION
23
+ servers: List[ServerInfo] = Field(default_factory=list)
24
+
25
+ APP_NAME = "hecaton"
26
+ APP_AUTHOR = "Just1truc"
27
+
28
+ def data_dir() -> Path:
29
+ d = user_data_path(appname=APP_NAME, appauthor=APP_AUTHOR, roaming=False)
30
+ d.mkdir(parents=True, exist_ok=True)
31
+ return d
32
+
33
+ def db_path() -> Path:
34
+ return data_dir() / "db.json"
35
+
36
+ def lock_path() -> Path:
37
+ return data_dir() / "db.lock"
38
+
39
+ def load_db() -> Database:
40
+ p = db_path()
41
+ if not p.exists():
42
+ return Database()
43
+ raw = json.loads(p.read_text(encoding="utf-8"))
44
+ return Database.model_validate(raw)
45
+
46
+ def _atomic_write(path: Path, data: str) -> None:
47
+ tmp = path.with_suffix(path.suffix + ".tmp")
48
+ tmp.write_text(data, encoding="utf-8")
49
+ tmp.replace(path)
50
+
51
+ def save_db(db: Database) -> None:
52
+ text = db.model_dump_json(indent=2, by_alias=True)
53
+ _atomic_write(db_path(), text)
54
+
55
+ @contextmanager
56
+ def with_locked_db(mutate: bool = False):
57
+ """Read DB under a lock; optionally write back on exit if mutate=True."""
58
+ lock = FileLock(str(lock_path()))
59
+ with lock:
60
+ db = load_db()
61
+ yield db
62
+ if mutate:
63
+ save_db(db)
@@ -0,0 +1,129 @@
1
+ import typer
2
+ import os
3
+ import click
4
+ from typing import Optional, List, Tuple
5
+ from hecaton.client.managers.api import HecatonServer
6
+ from hecaton.client.managers.server import ServerManager, ServerInfo
7
+
8
+ image_app = typer.Typer()
9
+
10
+ class ImageManager:
11
+
12
+ def list_images(
13
+ self,
14
+ ip,
15
+ secret
16
+ ):
17
+ return HecatonServer.list_images(ip, secret)
18
+
19
+ def new_image(
20
+ self,
21
+ ip,
22
+ secret,
23
+ image
24
+ ):
25
+ return HecatonServer.new_image(ip, secret, image)
26
+
27
+ def update_image(
28
+ self,
29
+ ip,
30
+ secret,
31
+ image : str,
32
+ env : List[Tuple[str, str]] | None = None,
33
+ description : Optional[str] = None
34
+ ):
35
+ return HecatonServer.update_image(ip, secret, image, env, description)
36
+
37
+
38
+ @image_app.command("list")
39
+ def list_image(
40
+ ctx : typer.Context
41
+ ):
42
+ mgr : ImageManager = ctx.obj["image_mgr"]
43
+ server_mgr : ServerManager = ctx.obj["server_mgr"]
44
+ server_info : ServerInfo = server_mgr.connected_server()
45
+
46
+ images = mgr.list_images(server_info.ip, server_info.secret)
47
+ for image in images:
48
+ typer.echo(image[1])
49
+
50
+ @image_app.command("new")
51
+ def new_image(
52
+ ctx : typer.Context,
53
+ image : str
54
+ ):
55
+ mgr : ImageManager = ctx.obj["image_mgr"]
56
+ server_mgr : ServerManager = ctx.obj["server_mgr"]
57
+ server_info : ServerInfo = server_mgr.connected_server()
58
+
59
+ typer.echo(mgr.new_image(server_info.ip, server_info.secret, image))
60
+
61
+
62
+ def complete_image_name(ctx : typer.Context, param: click.Parameter, incomplete : str) -> List[str]:
63
+
64
+ mgr : ImageManager = ctx.obj["image_mgr"]
65
+ server_mgr : ServerManager = ctx.obj["server_mgr"]
66
+ server_info : ServerInfo = server_mgr.connected_server()
67
+
68
+ images = mgr.list_images(server_info.ip, server_info.secret)
69
+ return [image[1] for image in images if image[1].startswith(incomplete)]
70
+
71
+ def prompt_optional(label: str, *, hide: bool = False):
72
+ def _cb(ctx: click.Context, param: click.Parameter, value: Optional[str]):
73
+ # Don't prompt during shell completion / help rendering
74
+ if ctx.resilient_parsing:
75
+ return value
76
+ if value is not None:
77
+ return value
78
+ # Prompt once; empty -> None
79
+ ans = typer.prompt(label, default="", show_default=False, hide_input=hide)
80
+ return ans if ans.strip() else None
81
+ return _cb
82
+
83
+ @image_app.command("update")
84
+ def update_image(
85
+ ctx : typer.Context,
86
+ image : str = typer.Argument(..., shell_complete=complete_image_name),
87
+ env_file_path : Optional[str] = typer.Option(
88
+ None,
89
+ "--fp",
90
+ callback=prompt_optional(" New_env_path (Press Enter to keep current variables)"),
91
+ help="Env Path",
92
+ show_default=False,
93
+ ),
94
+ description : Optional[str] = typer.Option(
95
+ None,
96
+ "--desc",
97
+ callback=prompt_optional(" Description (Press Enter to keep current value)"),
98
+ help="Description",
99
+ show_default=False,
100
+ )
101
+ ):
102
+ mgr : ImageManager = ctx.obj["image_mgr"]
103
+ server_mgr : ServerManager = ctx.obj["server_mgr"]
104
+ server_info : ServerInfo = server_mgr.connected_server()
105
+
106
+ env = None
107
+ if env_file_path and not os.path.isfile(env_file_path):
108
+ typer.echo(f"error: Invalid filepath {env_file_path}")
109
+ return
110
+ elif os.path.isfile(env_file_path):
111
+ env = [line.split("=") for line in open(env_file_path, "r").read().split() if "=" in line]
112
+
113
+ typer.echo(mgr.update_image(server_info.ip, server_info.secret, image, env, description))
114
+
115
+ @image_app.command("show")
116
+ def image_info(
117
+ ctx : typer.Context,
118
+ image : str = typer.Argument(..., shell_complete=complete_image_name)
119
+ ):
120
+ mgr : ImageManager = ctx.obj["image_mgr"]
121
+ server_mgr : ServerManager = ctx.obj["server_mgr"]
122
+ server_info : ServerInfo = server_mgr.connected_server()
123
+
124
+ images = mgr.list_images(server_info.ip, server_info.secret)
125
+ for im in images:
126
+ if im[1] == image:
127
+ typer.echo(f'name: \t\t {im[1]}')
128
+ typer.echo(f'description: \t\t {im[2] or "No Description yet..."}')
129
+ typer.echo(f'env: \t\t {im[3]}')