gax-cli 0.4.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.
Files changed (66) hide show
  1. gax/__init__.py +3 -0
  2. gax/adapters/__init__.py +3 -0
  3. gax/adapters/base.py +25 -0
  4. gax/adapters/exec_adapter.py +261 -0
  5. gax/adapters/http_adapter.py +33 -0
  6. gax/adapters/k8s_adapter.py +338 -0
  7. gax/adapters/mcp_bridge.py +108 -0
  8. gax/adapters/mock_adapter.py +49 -0
  9. gax/audit.py +54 -0
  10. gax/caps.py +91 -0
  11. gax/cli.py +722 -0
  12. gax/client.py +58 -0
  13. gax/compliance.py +71 -0
  14. gax/config/oauth_providers.yaml +21 -0
  15. gax/config/policy.rego +20 -0
  16. gax/config/policy.yaml +59 -0
  17. gax/daemon.py +187 -0
  18. gax/envelope.py +64 -0
  19. gax/executor.py +151 -0
  20. gax/macaroons_cap.py +80 -0
  21. gax/manifests/aws.s3.list.yaml +20 -0
  22. gax/manifests/demo.echo.yaml +20 -0
  23. gax/manifests/gh.pr.list.yaml +41 -0
  24. gax/manifests/gh.pr.view.yaml +30 -0
  25. gax/manifests/jira.issue.get.yaml +18 -0
  26. gax/manifests/k8s.deployment.scale.yaml +35 -0
  27. gax/manifests/k8s.pod.delete.yaml +30 -0
  28. gax/manifests/kubectl.get.pods.yaml +20 -0
  29. gax/manifests/mcp.github.list_pulls.yaml +29 -0
  30. gax/manifests/pet_findpetsbystatus.yaml +19 -0
  31. gax/manifests/pet_getpetbyid.yaml +19 -0
  32. gax/mcp_client.py +110 -0
  33. gax/mcp_server.py +299 -0
  34. gax/oauth.py +184 -0
  35. gax/onboarding.py +397 -0
  36. gax/opa_policy.py +46 -0
  37. gax/openapi_gen.py +96 -0
  38. gax/otel_audit.py +50 -0
  39. gax/paths.py +46 -0
  40. gax/plan.py +151 -0
  41. gax/policy.py +33 -0
  42. gax/policy_bundle.py +64 -0
  43. gax/profiles/github/gh.issue.list.yaml +25 -0
  44. gax/profiles/github/gh.issue.view.yaml +23 -0
  45. gax/profiles/github/gh.pr.comment.yaml +30 -0
  46. gax/profiles/github/gh.pr.merge.yaml +29 -0
  47. gax/profiles/github/gh.run.list.yaml +22 -0
  48. gax/profiles/k8s/k8s.deployment.list.yaml +17 -0
  49. gax/profiles/k8s/k8s.deployment.restart.yaml +25 -0
  50. gax/profiles/k8s/k8s.namespace.delete.yaml +22 -0
  51. gax/profiles/k8s/k8s.pod.describe.yaml +22 -0
  52. gax/profiles/k8s/k8s.pod.logs.yaml +28 -0
  53. gax/profiles/k8s/k8s.service.list.yaml +17 -0
  54. gax/profiles.py +143 -0
  55. gax/projection.py +42 -0
  56. gax/registry.py +130 -0
  57. gax/schemas/envelope.v1.json +46 -0
  58. gax/secrets.py +49 -0
  59. gax/side_effects.py +67 -0
  60. gax/spiffe.py +32 -0
  61. gax/vault.py +69 -0
  62. gax_cli-0.4.0.dist-info/METADATA +164 -0
  63. gax_cli-0.4.0.dist-info/RECORD +66 -0
  64. gax_cli-0.4.0.dist-info/WHEEL +5 -0
  65. gax_cli-0.4.0.dist-info/entry_points.txt +4 -0
  66. gax_cli-0.4.0.dist-info/top_level.txt +1 -0
gax/onboarding.py ADDED
@@ -0,0 +1,397 @@
1
+ """
2
+ `gax init` and `gax doctor` — the on-ramp.
3
+
4
+ We measured the pre-existing first-run path in a clean virtualenv: a new engineer
5
+ hit **two dead ends** before their first successful command. `gax demo.echo` returned
6
+ a bare `[Errno 61] Connection refused` with no hint the sidecar wasn't running, and
7
+ `gax --local demo.echo` then failed with `missing capability token`. Both are
8
+ correct behavior and both are useless as a first experience.
9
+
10
+ `init` collapses that into one command. `doctor` diagnoses the same failures after
11
+ the fact. Neither invents state silently: `init` reports exactly what it did, and is
12
+ idempotent so re-running is safe.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ import sys
22
+ import time
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from gax.paths import (
28
+ AUDIT_PATH,
29
+ CONFIG_PATH,
30
+ DEFAULT_HOST,
31
+ DEFAULT_PORT,
32
+ GAX_HOME,
33
+ PID_PATH,
34
+ ensure_gax_home,
35
+ )
36
+
37
+ CAP_PATH = GAX_HOME / "dev_cap.jwt"
38
+
39
+ # Enough to be useful immediately, all read-only. Destructive commands are
40
+ # deliberately excluded: a dev capability minted by `init` must not be able to
41
+ # delete anything, and raising its ceiling should be a conscious act.
42
+ DEV_COMMANDS = [
43
+ "demo.echo",
44
+ "gh.pr.list",
45
+ "gh.pr.view",
46
+ "k8s.pod.list",
47
+ "kubectl.get.pods",
48
+ "aws.s3.list",
49
+ "jira.issue.get",
50
+ ]
51
+ DEV_SCOPES = [
52
+ "demo:echo",
53
+ "github:pull_request:read",
54
+ "k8s:pods:read",
55
+ "aws:s3:read",
56
+ "jira:issue:read",
57
+ ]
58
+ DEV_TTL_SECONDS = 30 * 24 * 3600 # 30 days — long enough to not derail a trial
59
+
60
+
61
+ @dataclass
62
+ class Check:
63
+ name: str
64
+ ok: bool
65
+ detail: str
66
+ fix: str = ""
67
+
68
+ @property
69
+ def symbol(self) -> str:
70
+ return "✓" if self.ok else "✗"
71
+
72
+
73
+ def daemon_health(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> dict[str, Any] | None:
74
+ try:
75
+ import httpx
76
+
77
+ r = httpx.get(f"http://{host}:{port}/health", timeout=1.5)
78
+ return r.json() if r.status_code == 200 else None
79
+ except Exception:
80
+ return None
81
+
82
+
83
+ def daemon_running(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> bool:
84
+ return daemon_health(host, port) is not None
85
+
86
+
87
+ def config_fingerprint() -> str:
88
+ """Fingerprint of the local signing secret — never the secret itself."""
89
+ import hashlib
90
+
91
+ from gax.caps import jwt_secret
92
+
93
+ return hashlib.sha256(jwt_secret().encode()).hexdigest()[:12]
94
+
95
+
96
+ def daemon_config_matches(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> bool | None:
97
+ """
98
+ Whether a running daemon shares our signing secret.
99
+
100
+ A daemon started against a different `~/.gax` accepts connections but rejects
101
+ every capability with "Signature verification failed" — a confusing failure
102
+ that looks like a broken token rather than a stale process. `None` means the
103
+ daemon is unreachable or too old to report a fingerprint.
104
+ """
105
+ health = daemon_health(host, port)
106
+ if not health:
107
+ return None
108
+ remote = health.get("config_fingerprint")
109
+ if not remote:
110
+ return None
111
+ return remote == config_fingerprint()
112
+
113
+
114
+ def _start_daemon(host: str, port: int) -> bool:
115
+ """Spawn gaxd detached. Returns True once /health answers."""
116
+ exe = shutil.which("gaxd")
117
+ cmd = [exe, "start"] if exe else [sys.executable, "-m", "gax.daemon", "start"]
118
+ try:
119
+ subprocess.Popen(
120
+ [*cmd, "--host", host, "--port", str(port)],
121
+ stdout=subprocess.DEVNULL,
122
+ stderr=subprocess.DEVNULL,
123
+ start_new_session=True,
124
+ )
125
+ except Exception:
126
+ return False
127
+ for _ in range(30): # up to ~6s
128
+ if daemon_running(host, port):
129
+ return True
130
+ time.sleep(0.2)
131
+ return False
132
+
133
+
134
+ def _cap_claims(token: str) -> dict[str, Any] | None:
135
+ try:
136
+ from gax.caps import decode_capability
137
+
138
+ return decode_capability(token)
139
+ except Exception:
140
+ return None
141
+
142
+
143
+ def read_saved_cap() -> str | None:
144
+ if not CAP_PATH.exists():
145
+ return None
146
+ token = CAP_PATH.read_text().strip()
147
+ return token or None
148
+
149
+
150
+ def run_init(
151
+ *,
152
+ host: str = DEFAULT_HOST,
153
+ port: int = DEFAULT_PORT,
154
+ start_daemon: bool = True,
155
+ force: bool = False,
156
+ profiles: list[str] | None = None,
157
+ ) -> dict[str, Any]:
158
+ """
159
+ Idempotent setup. Returns a report rather than printing, so the CLI owns
160
+ presentation and tests can assert on structure.
161
+ """
162
+ steps: list[str] = []
163
+ ensure_gax_home()
164
+ steps.append(f"created {GAX_HOME}")
165
+
166
+ # Profiles first: the capability minted below should cover what got installed.
167
+ installed_profiles: list[dict[str, Any]] = []
168
+ for name in profiles or []:
169
+ from gax.profiles import install_profile
170
+
171
+ try:
172
+ result = install_profile(name, force=force)
173
+ except KeyError:
174
+ steps.append(f"unknown profile '{name}' — skipped")
175
+ continue
176
+ installed_profiles.append(result)
177
+ n = len(result["installed"])
178
+ detail = f"installed profile '{name}' ({n} commands: {result['summary']})"
179
+ if result["skipped"]:
180
+ detail += f", {len(result['skipped'])} already present"
181
+ steps.append(detail)
182
+
183
+ if not CONFIG_PATH.exists():
184
+ CONFIG_PATH.write_text(
185
+ json.dumps(
186
+ {
187
+ "jwt_secret": os.urandom(24).hex(),
188
+ "tenant_id": "default",
189
+ "subject": "dev@local",
190
+ },
191
+ indent=2,
192
+ )
193
+ )
194
+ steps.append("wrote config.json (random dev signing secret)")
195
+ else:
196
+ steps.append("config.json already present — kept")
197
+
198
+ # Mint only when missing or expired, so re-running init doesn't invalidate a
199
+ # capability the user already exported into their shell.
200
+ from gax.caps import mint_capability
201
+
202
+ # Cover the profile's read-only commands too, otherwise `init --profile k8s`
203
+ # registers commands the dev capability cannot call. Write/destructive commands
204
+ # are deliberately NOT added: the ceiling stays `read`, so reaching them
205
+ # requires minting a capability on purpose.
206
+ commands = list(DEV_COMMANDS)
207
+ scopes = list(DEV_SCOPES)
208
+ for result in installed_profiles:
209
+ from gax.profiles import get_profile
210
+
211
+ profile = get_profile(result["profile"])
212
+ if not profile:
213
+ continue
214
+ for cmd in profile.by_level("read"):
215
+ if cmd.command not in commands:
216
+ commands.append(cmd.command)
217
+ for scope in cmd.required_scopes:
218
+ if scope not in scopes:
219
+ scopes.append(scope)
220
+
221
+ existing = read_saved_cap()
222
+ reuse = bool(existing) and not force and _cap_claims(existing or "") is not None
223
+ # A profile installed after the capability was minted needs a re-mint, or its
224
+ # read commands would be registered but uncallable.
225
+ if reuse and installed_profiles:
226
+ claims = _cap_claims(existing or "") or {}
227
+ held = set(claims.get("commands") or [])
228
+ if not set(commands).issubset(held):
229
+ reuse = False
230
+
231
+ if reuse:
232
+ token = existing or ""
233
+ steps.append("dev capability still valid — kept")
234
+ else:
235
+ token = mint_capability(
236
+ commands=commands,
237
+ scopes=scopes,
238
+ ttl_seconds=DEV_TTL_SECONDS,
239
+ max_side_effect="read",
240
+ )
241
+ CAP_PATH.write_text(token)
242
+ CAP_PATH.chmod(0o600)
243
+ steps.append(
244
+ f"minted 30-day read-only dev capability ({len(commands)} commands) → {CAP_PATH}"
245
+ )
246
+
247
+ daemon_ok = daemon_running(host, port)
248
+ stale_daemon = False
249
+ if daemon_ok:
250
+ if daemon_config_matches(host, port) is False:
251
+ # Running, but signed with a different secret — every invoke would
252
+ # fail with "Signature verification failed". Report it loudly here
253
+ # rather than let the user debug a token that is actually fine.
254
+ stale_daemon = True
255
+ steps.append(
256
+ f"gaxd on {host}:{port} uses a DIFFERENT config — "
257
+ "restart it: gaxd stop && gaxd start --background"
258
+ )
259
+ else:
260
+ steps.append(f"gaxd already running on {host}:{port}")
261
+ elif start_daemon:
262
+ daemon_ok = _start_daemon(host, port)
263
+ steps.append(
264
+ f"started gaxd on {host}:{port}" if daemon_ok else "could not start gaxd"
265
+ )
266
+
267
+ return {
268
+ "gax_home": str(GAX_HOME),
269
+ "capability_path": str(CAP_PATH),
270
+ "capability": token,
271
+ "daemon_running": daemon_ok,
272
+ "daemon_config_mismatch": stale_daemon,
273
+ "host": host,
274
+ "port": port,
275
+ "steps": steps,
276
+ "commands": DEV_COMMANDS,
277
+ }
278
+
279
+
280
+ def run_doctor(
281
+ *,
282
+ host: str = DEFAULT_HOST,
283
+ port: int = DEFAULT_PORT,
284
+ ) -> list[Check]:
285
+ """Diagnose the failures a first-run user actually hits."""
286
+ checks: list[Check] = []
287
+
288
+ checks.append(
289
+ Check(
290
+ "gax home",
291
+ GAX_HOME.exists(),
292
+ str(GAX_HOME) if GAX_HOME.exists() else "missing",
293
+ fix="run: gax init",
294
+ )
295
+ )
296
+
297
+ checks.append(
298
+ Check(
299
+ "config",
300
+ CONFIG_PATH.exists(),
301
+ "config.json present" if CONFIG_PATH.exists() else "missing",
302
+ fix="run: gax init",
303
+ )
304
+ )
305
+
306
+ # Registry
307
+ try:
308
+ from gax.registry import Registry
309
+
310
+ n = len(Registry().list_commands())
311
+ checks.append(Check("registry", n > 0, f"{n} commands registered",
312
+ fix="check manifests/ directory"))
313
+ except Exception as e:
314
+ checks.append(Check("registry", False, f"failed to load: {e}"))
315
+
316
+ # Capability: env wins over saved file, since that is what invokes actually use.
317
+ env_cap = os.environ.get("GAX_CAP", "").strip()
318
+ saved = read_saved_cap()
319
+ token = env_cap or saved or ""
320
+ source = "GAX_CAP env" if env_cap else ("~/.gax/dev_cap.jwt" if saved else "none")
321
+ if not token:
322
+ checks.append(
323
+ Check("capability", False, "no capability found",
324
+ fix="run: gax init, then eval \"$(gax init --print-export)\"")
325
+ )
326
+ else:
327
+ claims = _cap_claims(token)
328
+ if claims is None:
329
+ checks.append(
330
+ Check("capability", False, f"invalid or expired ({source})",
331
+ fix="run: gax init --force")
332
+ )
333
+ else:
334
+ exp = claims.get("exp")
335
+ left = int(exp - time.time()) if isinstance(exp, (int, float)) else None
336
+ detail = f"valid from {source}"
337
+ if left is not None:
338
+ detail += f", expires in {left // 86400}d {left % 86400 // 3600}h"
339
+ detail += f", ceiling={claims.get('max_side_effect', 'read')}"
340
+ checks.append(Check("capability", True, detail))
341
+ if not env_cap and saved:
342
+ # Not a failure: the CLI falls back to the saved capability, so
343
+ # commands work without exporting. Only other tools (raw HTTP,
344
+ # the MCP server under some launchers) need the env var, so this
345
+ # is informational — flagging it red would be a false alarm.
346
+ checks.append(
347
+ Check(
348
+ "GAX_CAP env",
349
+ True,
350
+ 'not set; CLI uses ~/.gax/dev_cap.jwt. For other tools: '
351
+ 'eval "$(gax init --print-export)"',
352
+ )
353
+ )
354
+
355
+ running = daemon_running(host, port)
356
+ checks.append(
357
+ Check(
358
+ "gaxd sidecar",
359
+ running,
360
+ f"responding on {host}:{port}" if running else f"not reachable on {host}:{port}",
361
+ fix="run: gaxd start --background (or use: gax --local <cmd>)",
362
+ )
363
+ )
364
+ if running and daemon_config_matches(host, port) is False:
365
+ checks.append(
366
+ Check(
367
+ "gaxd config",
368
+ False,
369
+ "daemon is using a different signing secret — every invoke will "
370
+ "fail with 'Signature verification failed'",
371
+ fix="run: gaxd stop && gaxd start --background",
372
+ )
373
+ )
374
+
375
+ # Backends — informational; absence falls back to mocks rather than failing.
376
+ for binary, why in (("gh", "gh.pr.* commands"), ("kubectl", "k8s.* commands")):
377
+ found = shutil.which(binary)
378
+ checks.append(
379
+ Check(
380
+ f"{binary} binary",
381
+ bool(found),
382
+ found or f"not found — {why} will return mock data",
383
+ fix=f"install {binary} for live {why}",
384
+ )
385
+ )
386
+
387
+ checks.append(
388
+ Check(
389
+ "audit log",
390
+ True,
391
+ f"{AUDIT_PATH} ({AUDIT_PATH.stat().st_size} bytes)"
392
+ if AUDIT_PATH.exists()
393
+ else "no invocations recorded yet",
394
+ )
395
+ )
396
+
397
+ return checks
gax/opa_policy.py ADDED
@@ -0,0 +1,46 @@
1
+ """OPA/Rego policy evaluation (optional `opa` binary) with YAML fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from gax.policy_bundle import PolicyDenied, check_policy_bundle
12
+ from gax.paths import CONFIG_DIR
13
+ from gax.registry import CommandManifest
14
+
15
+ REGO_PATH = CONFIG_DIR / "policy.rego"
16
+
17
+
18
+ def check_opa_or_yaml(
19
+ claims: dict[str, Any],
20
+ manifest: CommandManifest,
21
+ args: dict[str, Any],
22
+ ) -> None:
23
+ if shutil.which("opa") and REGO_PATH.exists():
24
+ inp = {
25
+ "claims": claims,
26
+ "command": manifest.command,
27
+ "side_effects": manifest.side_effects,
28
+ "args": args,
29
+ }
30
+ proc = subprocess.run(
31
+ ["opa", "eval", "-d", str(REGO_PATH), "-I", "data", "data.gax.allow"],
32
+ input=json.dumps({"input": inp}),
33
+ capture_output=True,
34
+ text=True,
35
+ timeout=10,
36
+ )
37
+ if proc.returncode == 0:
38
+ try:
39
+ val = json.loads(proc.stdout)
40
+ allowed = val.get("result", [{}])[0].get("expressions", [{}])[0].get("value")
41
+ if allowed is False:
42
+ raise PolicyDenied("OPA policy denied invoke")
43
+ return
44
+ except (json.JSONDecodeError, IndexError, KeyError):
45
+ pass
46
+ check_policy_bundle(claims, manifest, args)
gax/openapi_gen.py ADDED
@@ -0,0 +1,96 @@
1
+ """Generate GAX manifests from OpenAPI 3.x (subset: GET operations)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+
13
+ def _slug(s: str) -> str:
14
+ s = re.sub(r"[^a-zA-Z0-9]+", ".", s.strip("/"))
15
+ return s.strip(".").lower()[:80] or "op"
16
+
17
+
18
+ def generate_manifests(
19
+ spec: dict[str, Any],
20
+ *,
21
+ prefix: str = "api",
22
+ adapter: str = "mock",
23
+ ) -> list[dict[str, Any]]:
24
+ paths = spec.get("paths") or {}
25
+ out: list[dict[str, Any]] = []
26
+ for path, methods in paths.items():
27
+ if not isinstance(methods, dict):
28
+ continue
29
+ for method, op in methods.items():
30
+ if method.lower() != "get" or not isinstance(op, dict):
31
+ continue
32
+ op_id = op.get("operationId") or f"{method}_{_slug(path)}"
33
+ cmd = f"{prefix}.{_slug(op_id)}"
34
+ params = op.get("parameters") or []
35
+ props: dict[str, Any] = {}
36
+ required: list[str] = []
37
+ path_params: list[str] = []
38
+ query_params: list[str] = []
39
+ for p in params:
40
+ if not isinstance(p, dict):
41
+ continue
42
+ name = p.get("name")
43
+ if not name:
44
+ continue
45
+ loc = p.get("in", "query")
46
+ props[name] = {"type": "string", "description": p.get("description", "")}
47
+ if p.get("required"):
48
+ required.append(name)
49
+ if loc == "path":
50
+ path_params.append(name)
51
+ elif loc == "query":
52
+ query_params.append(name)
53
+ manifest: dict[str, Any] = {
54
+ "command": cmd,
55
+ "version": "1.0.0",
56
+ "description": op.get("summary") or op.get("description") or cmd,
57
+ "category": prefix,
58
+ "adapter": adapter,
59
+ "required_scopes": [f"{prefix}:read"],
60
+ "side_effects": "read",
61
+ "idempotent": True,
62
+ "input_schema": {
63
+ "type": "object",
64
+ "properties": props,
65
+ "required": required,
66
+ },
67
+ "output_schema": {"type": "object"},
68
+ }
69
+ if adapter == "http":
70
+ servers = spec.get("servers") or [{"url": "https://api.example.com"}]
71
+ base = servers[0].get("url", "").rstrip("/")
72
+ manifest["http"] = {
73
+ "method": "GET",
74
+ "url": base + path,
75
+ "path_params": path_params,
76
+ "query_params": query_params,
77
+ }
78
+ out.append(manifest)
79
+ return out
80
+
81
+
82
+ def write_manifests(manifests: list[dict[str, Any]], out_dir: Path) -> list[Path]:
83
+ out_dir.mkdir(parents=True, exist_ok=True)
84
+ written: list[Path] = []
85
+ for m in manifests:
86
+ path = out_dir / f"{m['command'].replace('.', '_')}.yaml"
87
+ path.write_text(yaml.safe_dump(m, sort_keys=False))
88
+ written.append(path)
89
+ return written
90
+
91
+
92
+ def load_spec(path: Path) -> dict[str, Any]:
93
+ text = path.read_text()
94
+ if path.suffix in (".yaml", ".yml"):
95
+ return yaml.safe_load(text) or {}
96
+ return json.loads(text)
gax/otel_audit.py ADDED
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+
8
+ from gax.paths import GAX_HOME, ensure_gax_home
9
+
10
+ OTEL_LOG_PATH = GAX_HOME / "audit.otel.jsonl"
11
+
12
+
13
+ def export_audit_event(
14
+ *,
15
+ audit_id: str,
16
+ tenant_id: str,
17
+ subject: str,
18
+ command: str,
19
+ ok: bool,
20
+ duration_ms: float | None = None,
21
+ error_kind: str | None = None,
22
+ ) -> None:
23
+ """Append OpenTelemetry-log-compatible JSON (stdout or file)."""
24
+ if os.environ.get("GAX_OTEL_STDOUT") == "1":
25
+ sink = None
26
+ else:
27
+ ensure_gax_home()
28
+ sink = OTEL_LOG_PATH
29
+
30
+ body = {
31
+ "timestamp": datetime.now(timezone.utc).isoformat(),
32
+ "severity_text": "INFO" if ok else "ERROR",
33
+ "body": f"gax.invoke {command}",
34
+ "attributes": {
35
+ "gax.audit_id": audit_id,
36
+ "gax.tenant_id": tenant_id,
37
+ "gax.subject": subject,
38
+ "gax.command": command,
39
+ "gax.ok": ok,
40
+ "gax.duration_ms": duration_ms,
41
+ "gax.error_kind": error_kind or "",
42
+ },
43
+ "trace_id": audit_id.replace("aud_", "")[:32].ljust(32, "0"),
44
+ }
45
+ line = json.dumps(body) + "\n"
46
+ if sink is None:
47
+ print(line, end="")
48
+ else:
49
+ with sink.open("a") as f:
50
+ f.write(line)
gax/paths.py ADDED
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ _PKG_DIR = Path(__file__).resolve().parent # .../gax/gax
6
+ _REPO_ROOT = _PKG_DIR.parent # .../gax (repo checkout only)
7
+
8
+
9
+ def _data_dir(name: str) -> Path:
10
+ """
11
+ Locate a bundled data directory.
12
+
13
+ Installed wheels carry these *inside* the package (`gax/gax/manifests`), while
14
+ a repo checkout keeps the editable sources one level up (`gax/manifests`, next
15
+ to `gax/gax`).
16
+
17
+ The repo copy wins when both exist. `sync_package_data.py` leaves a build
18
+ artifact at `gax/gax/<name>/`, and preferring it would silently shadow the
19
+ sources — editing `config/policy.yaml` would appear to do nothing until the
20
+ next sync. In an installed wheel there is no repo copy, so the packaged one
21
+ is used.
22
+ """
23
+ repo_copy = _REPO_ROOT / name
24
+ if repo_copy.exists() and repo_copy != _PKG_DIR / name:
25
+ return repo_copy
26
+ return _PKG_DIR / name
27
+
28
+
29
+ MANIFESTS_DIR = _data_dir("manifests")
30
+ SCHEMAS_DIR = _data_dir("schemas")
31
+ CONFIG_DIR = _data_dir("config")
32
+ PROFILES_DIR = _data_dir("profiles")
33
+ GAX_HOME = Path.home() / ".gax"
34
+ # User-installed commands (profiles, hand-written manifests). Loaded after the
35
+ # packaged set and wins on conflict, so upgrades never clobber local commands.
36
+ USER_MANIFESTS_DIR = GAX_HOME / "manifests"
37
+ CONFIG_PATH = GAX_HOME / "config.json"
38
+ AUDIT_PATH = GAX_HOME / "audit.jsonl"
39
+ PID_PATH = GAX_HOME / "gaxd.pid"
40
+ DEFAULT_HOST = "127.0.0.1"
41
+ DEFAULT_PORT = 9477
42
+
43
+
44
+ def ensure_gax_home() -> Path:
45
+ GAX_HOME.mkdir(parents=True, exist_ok=True)
46
+ return GAX_HOME