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/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