alloc 0.0.1__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.
- alloc/__init__.py +11 -0
- alloc/artifact_writer.py +67 -0
- alloc/callbacks.py +342 -0
- alloc/catalog/__init__.py +138 -0
- alloc/catalog/default_rate_card.json +18 -0
- alloc/catalog/gpus.v1.json +174 -0
- alloc/cli.py +1341 -0
- alloc/config.py +124 -0
- alloc/context.py +191 -0
- alloc/display.py +580 -0
- alloc/extractor_runner.py +141 -0
- alloc/ghost.py +167 -0
- alloc/model_extractor.py +170 -0
- alloc/model_registry.py +138 -0
- alloc/probe.py +461 -0
- alloc/stability.py +144 -0
- alloc/upload.py +138 -0
- alloc/yaml_config.py +287 -0
- alloc-0.0.1.dist-info/METADATA +256 -0
- alloc-0.0.1.dist-info/RECORD +23 -0
- alloc-0.0.1.dist-info/WHEEL +5 -0
- alloc-0.0.1.dist-info/entry_points.txt +2 -0
- alloc-0.0.1.dist-info/top_level.txt +1 -0
alloc/config.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Alloc CLI configuration — persistent config file + env var overrides."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
def _config_dir() -> Path:
|
|
11
|
+
# Compute dynamically so tests and containerized runs can override HOME.
|
|
12
|
+
return Path.home() / ".alloc"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _config_file() -> Path:
|
|
16
|
+
return _config_dir() / "config.json"
|
|
17
|
+
|
|
18
|
+
_DEFAULT_API_URL = "https://alloc-production-ffc2.up.railway.app"
|
|
19
|
+
_DEFAULT_SUPABASE_URL = "https://stysqykttruzpcnzxshp.supabase.co"
|
|
20
|
+
_DEFAULT_SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InN0eXNxeWt0dHJ1enBjbnp4c2hwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA1ODgzOTQsImV4cCI6MjA4NjE2NDM5NH0.cHOBh5ei90Vj359TesKJ5GMZlyLoWFkMoNYs-HrKAtw"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_config() -> dict:
|
|
24
|
+
"""Read ~/.alloc/config.json or return empty dict."""
|
|
25
|
+
try:
|
|
26
|
+
cfg_file = _config_file()
|
|
27
|
+
if cfg_file.exists():
|
|
28
|
+
return json.loads(cfg_file.read_text())
|
|
29
|
+
except Exception:
|
|
30
|
+
pass
|
|
31
|
+
return {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def save_config(data: dict) -> None:
|
|
35
|
+
"""Write data to ~/.alloc/config.json. Creates dir if needed."""
|
|
36
|
+
try:
|
|
37
|
+
cfg_dir = _config_dir()
|
|
38
|
+
cfg_dir.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
os.chmod(cfg_dir, 0o700)
|
|
40
|
+
cfg_file = _config_file()
|
|
41
|
+
cfg_file.write_text(json.dumps(data, indent=2) + "\n")
|
|
42
|
+
os.chmod(cfg_file, 0o600)
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_token() -> str:
|
|
48
|
+
"""Auth token. Env var takes precedence over config file."""
|
|
49
|
+
env = os.environ.get("ALLOC_TOKEN", "")
|
|
50
|
+
if env:
|
|
51
|
+
return env
|
|
52
|
+
return load_config().get("token", "")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_api_url() -> str:
|
|
56
|
+
"""API URL. Env var > config file > default."""
|
|
57
|
+
env = os.environ.get("ALLOC_API_URL", "")
|
|
58
|
+
if env:
|
|
59
|
+
return env
|
|
60
|
+
return load_config().get("api_url", _DEFAULT_API_URL)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_supabase_url() -> str:
|
|
64
|
+
"""Supabase URL. Env var > default."""
|
|
65
|
+
return os.environ.get("ALLOC_SUPABASE_URL", _DEFAULT_SUPABASE_URL)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_supabase_anon_key() -> str:
|
|
69
|
+
"""Supabase anon key. Env var > default."""
|
|
70
|
+
return os.environ.get("ALLOC_SUPABASE_ANON_KEY", _DEFAULT_SUPABASE_ANON_KEY)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def should_upload() -> bool:
|
|
74
|
+
"""Whether to upload results to the Alloc dashboard."""
|
|
75
|
+
return os.environ.get("ALLOC_UPLOAD", "").lower() in ("1", "true", "yes")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def try_refresh_access_token() -> Optional[str]:
|
|
79
|
+
"""Attempt to refresh the saved access token using refresh_token.
|
|
80
|
+
|
|
81
|
+
Returns the new access token on success, otherwise None.
|
|
82
|
+
|
|
83
|
+
Notes:
|
|
84
|
+
- If ALLOC_TOKEN is set, this function returns None (env tokens can't be updated).
|
|
85
|
+
- This calls Supabase directly, so it requires ALLOC_SUPABASE_URL and
|
|
86
|
+
ALLOC_SUPABASE_ANON_KEY (or defaults) to be correct.
|
|
87
|
+
"""
|
|
88
|
+
if os.environ.get("ALLOC_TOKEN"):
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
cfg = load_config()
|
|
92
|
+
refresh_token = (cfg.get("refresh_token") or "").strip()
|
|
93
|
+
if not refresh_token:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
# Local import to keep config module import-time side effects minimal.
|
|
97
|
+
import httpx
|
|
98
|
+
|
|
99
|
+
supabase_url = get_supabase_url()
|
|
100
|
+
anon_key = get_supabase_anon_key()
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
with httpx.Client(timeout=15) as client:
|
|
104
|
+
resp = client.post(
|
|
105
|
+
f"{supabase_url}/auth/v1/token?grant_type=refresh_token",
|
|
106
|
+
json={"refresh_token": refresh_token},
|
|
107
|
+
headers={
|
|
108
|
+
"apikey": anon_key,
|
|
109
|
+
"Content-Type": "application/json",
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
resp.raise_for_status()
|
|
113
|
+
data = resp.json()
|
|
114
|
+
except Exception:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
access_token = (data.get("access_token") or "").strip()
|
|
118
|
+
if not access_token:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
cfg["token"] = access_token
|
|
122
|
+
cfg["refresh_token"] = (data.get("refresh_token") or refresh_token).strip()
|
|
123
|
+
save_config(cfg)
|
|
124
|
+
return access_token
|
alloc/context.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Context autodiscovery — capture environment metadata into the artifact.
|
|
2
|
+
|
|
3
|
+
All discovery functions are fail-safe: they return None on any error.
|
|
4
|
+
The artifact gets a `context` dict with optional fields. Never fails,
|
|
5
|
+
never slows down the CLI.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def discover_context() -> dict:
|
|
16
|
+
"""Discover all available environment context.
|
|
17
|
+
|
|
18
|
+
Returns a dict with optional keys: git, container, ray.
|
|
19
|
+
Only includes sections where data was found.
|
|
20
|
+
"""
|
|
21
|
+
ctx = {} # type: dict
|
|
22
|
+
|
|
23
|
+
git = _discover_git()
|
|
24
|
+
if git:
|
|
25
|
+
ctx["git"] = git
|
|
26
|
+
|
|
27
|
+
container = _discover_container()
|
|
28
|
+
if container:
|
|
29
|
+
ctx["container"] = container
|
|
30
|
+
|
|
31
|
+
ray = _discover_ray()
|
|
32
|
+
if ray:
|
|
33
|
+
ctx["ray"] = ray
|
|
34
|
+
|
|
35
|
+
return ctx
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _discover_git() -> Optional[dict]:
|
|
39
|
+
"""Discover git context: commit SHA, branch, repo name."""
|
|
40
|
+
try:
|
|
41
|
+
# Check if we're in a git repo
|
|
42
|
+
result = subprocess.run(
|
|
43
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
44
|
+
capture_output=True, text=True, timeout=5,
|
|
45
|
+
)
|
|
46
|
+
if result.returncode != 0:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
git = {} # type: dict
|
|
50
|
+
|
|
51
|
+
# Commit SHA
|
|
52
|
+
sha = subprocess.run(
|
|
53
|
+
["git", "rev-parse", "HEAD"],
|
|
54
|
+
capture_output=True, text=True, timeout=5,
|
|
55
|
+
)
|
|
56
|
+
if sha.returncode == 0 and sha.stdout.strip():
|
|
57
|
+
git["commit_sha"] = sha.stdout.strip()
|
|
58
|
+
|
|
59
|
+
# Branch name
|
|
60
|
+
branch = subprocess.run(
|
|
61
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
62
|
+
capture_output=True, text=True, timeout=5,
|
|
63
|
+
)
|
|
64
|
+
if branch.returncode == 0 and branch.stdout.strip():
|
|
65
|
+
git["branch"] = branch.stdout.strip()
|
|
66
|
+
|
|
67
|
+
# Repo name (from remote origin URL or top-level dir name)
|
|
68
|
+
remote = subprocess.run(
|
|
69
|
+
["git", "config", "--get", "remote.origin.url"],
|
|
70
|
+
capture_output=True, text=True, timeout=5,
|
|
71
|
+
)
|
|
72
|
+
if remote.returncode == 0 and remote.stdout.strip():
|
|
73
|
+
git["repo_url"] = remote.stdout.strip()
|
|
74
|
+
git["repo_name"] = _repo_name_from_url(remote.stdout.strip())
|
|
75
|
+
else:
|
|
76
|
+
# Fallback: use the top-level directory name
|
|
77
|
+
toplevel = subprocess.run(
|
|
78
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
79
|
+
capture_output=True, text=True, timeout=5,
|
|
80
|
+
)
|
|
81
|
+
if toplevel.returncode == 0 and toplevel.stdout.strip():
|
|
82
|
+
git["repo_name"] = os.path.basename(toplevel.stdout.strip())
|
|
83
|
+
|
|
84
|
+
return git if git else None
|
|
85
|
+
except Exception:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _repo_name_from_url(url: str) -> str:
|
|
90
|
+
"""Extract repo name from a git remote URL.
|
|
91
|
+
|
|
92
|
+
Handles:
|
|
93
|
+
git@github.com:org/repo.git → repo
|
|
94
|
+
https://github.com/org/repo.git → repo
|
|
95
|
+
https://github.com/org/repo → repo
|
|
96
|
+
"""
|
|
97
|
+
# Strip trailing .git
|
|
98
|
+
name = url.rstrip("/")
|
|
99
|
+
if name.endswith(".git"):
|
|
100
|
+
name = name[:-4]
|
|
101
|
+
# Take the last path component
|
|
102
|
+
return name.rsplit("/", 1)[-1].rsplit(":", 1)[-1]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _discover_container() -> Optional[dict]:
|
|
106
|
+
"""Discover container context: container ID, image name."""
|
|
107
|
+
try:
|
|
108
|
+
container = {} # type: dict
|
|
109
|
+
|
|
110
|
+
# Check for /.dockerenv (Docker) or /run/.containerenv (Podman)
|
|
111
|
+
in_docker = os.path.isfile("/.dockerenv")
|
|
112
|
+
in_podman = os.path.isfile("/run/.containerenv")
|
|
113
|
+
|
|
114
|
+
if not in_docker and not in_podman:
|
|
115
|
+
# Also check cgroup for container evidence
|
|
116
|
+
if not _check_cgroup_for_container():
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
# Container ID from /proc/self/cgroup or hostname
|
|
120
|
+
cid = _read_container_id()
|
|
121
|
+
if cid:
|
|
122
|
+
container["container_id"] = cid
|
|
123
|
+
|
|
124
|
+
# Image name from env var (commonly set by orchestrators)
|
|
125
|
+
image = os.environ.get("CONTAINER_IMAGE") or os.environ.get("DOCKER_IMAGE")
|
|
126
|
+
if image:
|
|
127
|
+
container["image"] = image
|
|
128
|
+
|
|
129
|
+
container["runtime"] = "podman" if in_podman else "docker"
|
|
130
|
+
|
|
131
|
+
return container if container else None
|
|
132
|
+
except Exception:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _check_cgroup_for_container() -> bool:
|
|
137
|
+
"""Check /proc/self/cgroup for container evidence."""
|
|
138
|
+
try:
|
|
139
|
+
with open("/proc/self/cgroup", "r") as f:
|
|
140
|
+
content = f.read()
|
|
141
|
+
return "docker" in content or "containerd" in content or "kubepods" in content
|
|
142
|
+
except Exception:
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _read_container_id() -> Optional[str]:
|
|
147
|
+
"""Read container ID from /proc/self/cgroup or hostname."""
|
|
148
|
+
# Try /proc/self/cgroup first
|
|
149
|
+
try:
|
|
150
|
+
with open("/proc/self/cgroup", "r") as f:
|
|
151
|
+
for line in f:
|
|
152
|
+
parts = line.strip().split("/")
|
|
153
|
+
for part in reversed(parts):
|
|
154
|
+
# Container IDs are 64-char hex strings
|
|
155
|
+
cleaned = part.split("-")[-1].rstrip(".scope")
|
|
156
|
+
if len(cleaned) == 64 and all(c in "0123456789abcdef" for c in cleaned):
|
|
157
|
+
return cleaned[:12] # Short form
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
# Fallback: hostname inside a container is often the short container ID
|
|
162
|
+
try:
|
|
163
|
+
hostname = os.environ.get("HOSTNAME", "")
|
|
164
|
+
if len(hostname) == 12 and all(c in "0123456789abcdef" for c in hostname):
|
|
165
|
+
return hostname
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _discover_ray() -> Optional[dict]:
|
|
173
|
+
"""Discover Ray context from environment variables."""
|
|
174
|
+
try:
|
|
175
|
+
ray = {} # type: dict
|
|
176
|
+
|
|
177
|
+
job_id = os.environ.get("RAY_JOB_ID")
|
|
178
|
+
if job_id:
|
|
179
|
+
ray["job_id"] = job_id
|
|
180
|
+
|
|
181
|
+
cluster = os.environ.get("RAY_CLUSTER_NAME") or os.environ.get("RAY_ADDRESS")
|
|
182
|
+
if cluster:
|
|
183
|
+
ray["cluster"] = cluster
|
|
184
|
+
|
|
185
|
+
node_id = os.environ.get("RAY_NODE_ID")
|
|
186
|
+
if node_id:
|
|
187
|
+
ray["node_id"] = node_id
|
|
188
|
+
|
|
189
|
+
return ray if ray else None
|
|
190
|
+
except Exception:
|
|
191
|
+
return None
|