trustarc-cli 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.
ta/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
File without changes
@@ -0,0 +1 @@
1
+ from ta.commands.auth.command import app as app
@@ -0,0 +1,91 @@
1
+ import time
2
+
3
+ import httpx
4
+ import typer
5
+ from rich.console import Console
6
+
7
+ from ta.lib.auth import auth_file, load_tokens, login, logout
8
+ from ta.lib.auth_servers import AUTH_BASE_URLS
9
+ from ta.lib.catalog_cache import evict_catalog
10
+ from ta.lib.config import UnknownEnvError, load_config, saved_env
11
+
12
+
13
+ def _help() -> str:
14
+ try:
15
+ return f"Manage authentication with the identity provider (env: {saved_env()})."
16
+ except UnknownEnvError:
17
+ return "Manage authentication with the identity provider."
18
+
19
+
20
+ app = typer.Typer(help=_help(), no_args_is_help=True)
21
+ console = Console()
22
+
23
+
24
+ def _resolve_env(env: str | None) -> str:
25
+ if not env:
26
+ return load_config()["env"]
27
+ if env not in AUTH_BASE_URLS:
28
+ console.print(f"[red]Unknown env: {env}[/red] Valid: {', '.join(AUTH_BASE_URLS)}")
29
+ raise typer.Exit(1)
30
+ return env
31
+
32
+
33
+ @app.command("login")
34
+ def login_cmd(
35
+ env: str = typer.Option(None, "--env", "-e", help="Target env (defaults to current config env)"),
36
+ ):
37
+ """Log in via browser (OAuth Authorization Code + PKCE)."""
38
+ env = _resolve_env(env)
39
+ try:
40
+ tokens = login(env)
41
+ except RuntimeError as e:
42
+ console.print(f"[red]Login failed:[/red] {e}")
43
+ raise typer.Exit(1) from e
44
+ expires = tokens.get("expires_at")
45
+ exp_str = f" (expires in {expires - int(time.time())}s)" if expires else ""
46
+ evict_catalog(env)
47
+ console.print(f"[green]Logged in to {env}[/green]{exp_str}")
48
+ console.print(f"[dim]Saved to {auth_file(env)}[/dim]")
49
+
50
+
51
+ @app.command("logout")
52
+ def logout_cmd(
53
+ env: str = typer.Option(None, "--env", "-e", help="Target env (defaults to current config env)"),
54
+ ):
55
+ """Revoke and clear saved tokens."""
56
+ env = _resolve_env(env)
57
+ logout(env)
58
+ evict_catalog(env)
59
+ console.print(f"[green]Logged out of {env}[/green]")
60
+
61
+
62
+ @app.command("status")
63
+ def status_cmd(
64
+ env: str = typer.Option(None, "--env", "-e", help="Target env (defaults to current config env)"),
65
+ ):
66
+ """Show authentication status."""
67
+ env = _resolve_env(env)
68
+ tokens = load_tokens(env)
69
+ if not tokens:
70
+ console.print(f"[yellow]Not logged in to {env}[/yellow]")
71
+ raise typer.Exit(1)
72
+
73
+ now = int(time.time())
74
+ remaining = tokens.get("expires_at", 0) - now
75
+ console.print(f"[bold]Env:[/bold] {env}")
76
+ if remaining > 0:
77
+ console.print(f"[green]Token valid[/green] ({remaining}s remaining)")
78
+ else:
79
+ console.print("[yellow]Token expired[/yellow] — `ta auth login` or it will auto-refresh on next use")
80
+
81
+ access = tokens.get("access_token")
82
+ userinfo_url = (tokens.get("oauth") or {}).get("userinfo_endpoint")
83
+ if userinfo_url and access:
84
+ try:
85
+ r = httpx.get(userinfo_url, headers={"Authorization": f"Bearer {access}"}, timeout=10)
86
+ if r.status_code == 200:
87
+ info = r.json()
88
+ email = info.get("email") or info.get("sub")
89
+ console.print(f"[bold]User:[/bold] {email}")
90
+ except httpx.HTTPError:
91
+ pass
@@ -0,0 +1 @@
1
+ from ta.commands.config.command import app as app
@@ -0,0 +1,57 @@
1
+ import typer
2
+ from rich.console import Console
3
+
4
+ from ta.lib.auth import load_tokens
5
+ from ta.lib.config import CONFIG_FILE, DEFAULT_CONFIG, UnknownEnvError, load_config, save_config, saved_env
6
+ from ta.lib.services import ENVS
7
+
8
+ app = typer.Typer(help="Manage ta configuration.", no_args_is_help=True)
9
+ console = Console()
10
+
11
+
12
+ @app.command("show")
13
+ def show():
14
+ """Show current configuration."""
15
+ try:
16
+ cfg = load_config()
17
+ except UnknownEnvError as e:
18
+ console.print(f"[yellow]{e}[/yellow]")
19
+ raise typer.Exit(1) from e
20
+ token_status = (
21
+ "[green]logged in[/green]" if load_tokens(cfg["env"]) else "[dim](not logged in — run `ta auth login`)[/dim]"
22
+ )
23
+ console.print(f"\n[bold]Env:[/bold] {cfg['env']}")
24
+ console.print(f"[bold]Auth:[/bold] {token_status}")
25
+ console.print("\n[bold]Services:[/bold]")
26
+ for name, spec_url in sorted(cfg["services"].items()):
27
+ console.print(f" {name}: {spec_url}")
28
+ console.print()
29
+
30
+
31
+ @app.command("setup")
32
+ def setup():
33
+ """Set up or update config file."""
34
+ try:
35
+ current_env = saved_env()
36
+ except UnknownEnvError as e:
37
+ console.print(f"[yellow]Saved env '{e.env}' is no longer supported — pick another one.[/yellow]")
38
+ current_env = DEFAULT_CONFIG["env"]
39
+
40
+ console.print("\nConfiguration (press Enter to keep current):\n")
41
+ env = typer.prompt(f" env ({'/'.join(ENVS)})", default=current_env)
42
+ if env not in ENVS:
43
+ console.print(f"[red]Unknown env: {env}[/red]")
44
+ raise typer.Exit(1)
45
+
46
+ save_config({"env": env})
47
+ console.print(f"\n[green]Saved {CONFIG_FILE}[/green]")
48
+
49
+
50
+ @app.command("reset")
51
+ def reset_config():
52
+ """Delete config file and use defaults."""
53
+ if CONFIG_FILE.exists():
54
+ CONFIG_FILE.unlink()
55
+ console.print(f"[green]Deleted {CONFIG_FILE}[/green]")
56
+ else:
57
+ console.print("[dim]No config file to delete[/dim]")
@@ -0,0 +1,2 @@
1
+ from ta.commands.self_update.command import self_update as self_update
2
+ from ta.commands.self_update.command import upgrade_command as upgrade_command
@@ -0,0 +1,36 @@
1
+ import subprocess
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ import typer
6
+
7
+ from ta import __version__
8
+ from ta.lib.update_check import PACKAGE_NAME, is_newer, latest_version
9
+
10
+
11
+ def upgrade_command(prefix: str = sys.prefix) -> list[str]:
12
+ parts = Path(prefix).parts
13
+ if "pipx" in parts:
14
+ return ["pipx", "upgrade", PACKAGE_NAME]
15
+ if "uv" in parts and "tools" in parts:
16
+ return ["uv", "tool", "upgrade", PACKAGE_NAME]
17
+ return [sys.executable, "-m", "pip", "install", "--upgrade", PACKAGE_NAME]
18
+
19
+
20
+ def self_update():
21
+ """Update ta to the latest version."""
22
+ latest = latest_version(force=True)
23
+ if latest and not is_newer(latest):
24
+ typer.echo(f"Already up to date ({__version__})")
25
+ raise typer.Exit()
26
+
27
+ if latest:
28
+ typer.echo(f"Updating {__version__} → {latest}...")
29
+ else:
30
+ typer.echo("Checking for updates...")
31
+
32
+ try:
33
+ result = subprocess.run(upgrade_command())
34
+ except FileNotFoundError:
35
+ result = subprocess.run([sys.executable, "-m", "pip", "install", "--upgrade", PACKAGE_NAME])
36
+ raise typer.Exit(result.returncode)
@@ -0,0 +1 @@
1
+ from ta.commands.services.command import services as services
@@ -0,0 +1,37 @@
1
+ import typer
2
+ from rich.console import Console
3
+ from rich.table import Table
4
+
5
+ from ta.lib.auth import get_access_token
6
+ from ta.lib.config import load_config
7
+
8
+ console = Console()
9
+
10
+
11
+ def services(
12
+ refresh: bool = typer.Option(False, "--refresh", help="Re-fetch the service catalog from the server."),
13
+ ):
14
+ """List configured services."""
15
+ cfg = load_config(refresh_catalog=refresh)
16
+ if not cfg["services"]:
17
+ if not get_access_token(cfg["env"]):
18
+ console.print(
19
+ f"[yellow]Not logged in to '{cfg['env']}'. Run `ta auth login` to load the service catalog.[/yellow]"
20
+ )
21
+ return
22
+ console.print(
23
+ f"[yellow]No services available for env '{cfg['env']}'. "
24
+ f"The service catalog could not be reached — check connectivity and try again.[/yellow]"
25
+ )
26
+ return
27
+ if refresh:
28
+ console.print("[green]Catalog refreshed.[/green]")
29
+ catalog = cfg["catalog"]
30
+ table = Table(title="Services", title_style="bold", show_lines=False, header_style="bold cyan")
31
+ table.add_column("Code", style="bold")
32
+ table.add_column("Name", style="dim")
33
+ table.add_column("Description")
34
+ for name in sorted(cfg["services"]):
35
+ entry = catalog[name]
36
+ table.add_row(name, entry.get("name", ""), entry.get("description", ""))
37
+ console.print(table)
ta/lib/__init__.py ADDED
File without changes
ta/lib/analytics.py ADDED
@@ -0,0 +1,63 @@
1
+ import contextlib
2
+ import json
3
+ import os
4
+ import subprocess
5
+ import sys
6
+
7
+ import httpx
8
+
9
+ from ta.lib.auth_servers import ARC_API_URLS
10
+
11
+ _TIMEOUT_S = 10.0
12
+ _WORKER = [sys.executable, "-I", "-m", "ta.lib.analytics"]
13
+
14
+
15
+ def report_tool_call(
16
+ env: str,
17
+ token: str,
18
+ app: str,
19
+ resource: str,
20
+ action: str,
21
+ method: str,
22
+ status_code: int | None,
23
+ error_type: str | None = None,
24
+ ) -> None:
25
+ base_url = ARC_API_URLS.get(env)
26
+ if os.getenv("DO_NOT_TRACK") or not base_url or not token:
27
+ return
28
+ report = {
29
+ "url": f"{base_url}/api/analytics/tool-calls",
30
+ "token": token,
31
+ "payload": {
32
+ "app": app,
33
+ "resource": resource,
34
+ "action": action,
35
+ "method": method,
36
+ "status_code": status_code,
37
+ "error_type": error_type,
38
+ },
39
+ }
40
+ with contextlib.suppress(OSError):
41
+ process = subprocess.Popen(
42
+ _WORKER,
43
+ stdin=subprocess.PIPE,
44
+ stdout=subprocess.DEVNULL,
45
+ stderr=subprocess.DEVNULL,
46
+ start_new_session=True,
47
+ )
48
+ process.stdin.write(json.dumps(report).encode())
49
+ process.stdin.close()
50
+
51
+
52
+ def _send(report: dict) -> None:
53
+ with contextlib.suppress(httpx.HTTPError):
54
+ httpx.post(
55
+ report["url"],
56
+ json=report["payload"],
57
+ headers={"Authorization": f"Bearer {report['token']}"},
58
+ timeout=_TIMEOUT_S,
59
+ )
60
+
61
+
62
+ if __name__ == "__main__":
63
+ _send(json.load(sys.stdin))
ta/lib/auth.py ADDED
@@ -0,0 +1,212 @@
1
+ import base64
2
+ import hashlib
3
+ import http.server
4
+ import json
5
+ import os
6
+ import secrets
7
+ import socket
8
+ import threading
9
+ import time
10
+ import webbrowser
11
+ from pathlib import Path
12
+ from urllib.parse import parse_qs, urlencode, urlparse
13
+
14
+ import httpx
15
+
16
+ from ta.lib.auth_servers import auth_server_for
17
+ from ta.lib.config import CONFIG_DIR
18
+
19
+ AUTH_DIR = CONFIG_DIR / "auth"
20
+ REFRESH_LEEWAY_SECONDS = 60
21
+ CALLBACK_PORT = 8765
22
+
23
+
24
+ def auth_file(env: str) -> Path:
25
+ return AUTH_DIR / f"{env}.json"
26
+
27
+
28
+ def load_tokens(env: str) -> dict | None:
29
+ path = auth_file(env)
30
+ if not path.exists():
31
+ return None
32
+ with open(path) as f:
33
+ return json.load(f)
34
+
35
+
36
+ def save_tokens(env: str, tokens: dict) -> None:
37
+ AUTH_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
38
+ AUTH_DIR.chmod(0o700)
39
+ path = auth_file(env)
40
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
41
+ path.chmod(0o600)
42
+ with os.fdopen(fd, "w") as f:
43
+ json.dump(tokens, f, indent=2)
44
+
45
+
46
+ def clear_tokens(env: str) -> None:
47
+ path = auth_file(env)
48
+ if path.exists():
49
+ path.unlink()
50
+
51
+
52
+ def login(env: str) -> dict:
53
+ port = CALLBACK_PORT
54
+ verifier, challenge = _pkce()
55
+ state = secrets.token_urlsafe(16)
56
+
57
+ received: dict[str, str | None] = {"code": None, "state": None, "error": None}
58
+ done = threading.Event()
59
+
60
+ class Handler(http.server.BaseHTTPRequestHandler):
61
+ def do_GET(self):
62
+ if self.path.startswith("/callback"):
63
+ qs = parse_qs(urlparse(self.path).query)
64
+ received["code"] = qs.get("code", [None])[0]
65
+ received["state"] = qs.get("state", [None])[0]
66
+ received["error"] = qs.get("error", [None])[0]
67
+ self.send_response(200)
68
+ self.send_header("Content-Type", "text/html; charset=utf-8")
69
+ self.end_headers()
70
+ body = (
71
+ "Login successful. You can close this window."
72
+ if received["code"]
73
+ else f"Login failed: {received['error']}"
74
+ )
75
+ self.wfile.write(f"<html><body><h1>{body}</h1></body></html>".encode())
76
+ done.set()
77
+ else:
78
+ self.send_response(404)
79
+ self.end_headers()
80
+
81
+ def log_message(self, *_):
82
+ pass
83
+
84
+ server, redirect_uri = _serve_callback(Handler, port)
85
+ cfg = auth_server_for(env, redirect_uri)
86
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
87
+ thread.start()
88
+
89
+ params = {
90
+ "client_id": cfg["client_id"],
91
+ "response_type": "code",
92
+ "redirect_uri": redirect_uri,
93
+ "scope": cfg.get("scope", "openid"),
94
+ "state": state,
95
+ "code_challenge": challenge,
96
+ "code_challenge_method": "S256",
97
+ }
98
+ auth_url = f"{cfg['authorization_endpoint']}?{urlencode(params)}"
99
+
100
+ print(f"Opening browser to log in ({env})...")
101
+ print(f"If it doesn't open, visit:\n {auth_url}\n")
102
+ webbrowser.open(auth_url)
103
+
104
+ if not done.wait(timeout=300):
105
+ server.shutdown()
106
+ raise RuntimeError("Login timed out")
107
+ server.shutdown()
108
+
109
+ if received["error"]:
110
+ raise RuntimeError(f"Authorization error: {received['error']}")
111
+ if not received["code"]:
112
+ raise RuntimeError("No authorization code received")
113
+ if received["state"] != state:
114
+ raise RuntimeError("State mismatch — possible CSRF")
115
+
116
+ data = {
117
+ "grant_type": "authorization_code",
118
+ "code": received["code"],
119
+ "redirect_uri": redirect_uri,
120
+ "client_id": cfg["client_id"],
121
+ "code_verifier": verifier,
122
+ }
123
+ resp = httpx.post(cfg["token_endpoint"], data=data, timeout=30)
124
+ if resp.status_code != 200:
125
+ raise RuntimeError(f"Token exchange failed: {resp.status_code} {resp.text}")
126
+
127
+ tokens = resp.json()
128
+ tokens["saved_at"] = int(time.time())
129
+ if "expires_in" in tokens:
130
+ tokens["expires_at"] = tokens["saved_at"] + int(tokens["expires_in"])
131
+ tokens["oauth"] = {
132
+ "client_id": cfg["client_id"],
133
+ "token_endpoint": cfg["token_endpoint"],
134
+ "revocation_endpoint": cfg.get("revocation_endpoint"),
135
+ "userinfo_endpoint": cfg.get("userinfo_endpoint"),
136
+ }
137
+ save_tokens(env, tokens)
138
+ return tokens
139
+
140
+
141
+ def refresh(env: str) -> dict | None:
142
+ tokens = load_tokens(env)
143
+ if not tokens or not tokens.get("refresh_token"):
144
+ return None
145
+ oauth = tokens.get("oauth") or {}
146
+ token_endpoint = oauth.get("token_endpoint")
147
+ client_id = oauth.get("client_id")
148
+ if not token_endpoint or not client_id:
149
+ return None
150
+ data = {
151
+ "grant_type": "refresh_token",
152
+ "refresh_token": tokens["refresh_token"],
153
+ "client_id": client_id,
154
+ }
155
+ resp = httpx.post(token_endpoint, data=data, timeout=30)
156
+ if resp.status_code != 200:
157
+ return None
158
+ new_tokens = resp.json()
159
+ new_tokens["saved_at"] = int(time.time())
160
+ if "expires_in" in new_tokens:
161
+ new_tokens["expires_at"] = new_tokens["saved_at"] + int(new_tokens["expires_in"])
162
+ if "refresh_token" not in new_tokens and tokens.get("refresh_token"):
163
+ new_tokens["refresh_token"] = tokens["refresh_token"]
164
+ new_tokens["oauth"] = oauth
165
+ save_tokens(env, new_tokens)
166
+ return new_tokens
167
+
168
+
169
+ def get_access_token(env: str) -> str | None:
170
+ tokens = load_tokens(env)
171
+ if not tokens:
172
+ return None
173
+ if "expires_at" in tokens and tokens["expires_at"] - time.time() < REFRESH_LEEWAY_SECONDS:
174
+ refreshed = refresh(env)
175
+ if refreshed:
176
+ tokens = refreshed
177
+ return tokens.get("access_token")
178
+
179
+
180
+ def logout(env: str) -> None:
181
+ tokens = load_tokens(env)
182
+ if tokens:
183
+ oauth = tokens.get("oauth") or {}
184
+ revoke = oauth.get("revocation_endpoint")
185
+ client_id = oauth.get("client_id")
186
+ if revoke and client_id and tokens.get("refresh_token"):
187
+ try:
188
+ httpx.post(
189
+ revoke,
190
+ data={"token": tokens["refresh_token"], "client_id": client_id},
191
+ timeout=10,
192
+ )
193
+ except httpx.HTTPError:
194
+ pass
195
+ clear_tokens(env)
196
+
197
+
198
+ class _IPv6Server(http.server.HTTPServer):
199
+ address_family = socket.AF_INET6
200
+
201
+
202
+ def _serve_callback(handler: type, port: int) -> tuple[http.server.HTTPServer, str]:
203
+ try:
204
+ return _IPv6Server(("::1", port), handler), f"http://[::1]:{port}/callback"
205
+ except OSError:
206
+ return http.server.HTTPServer(("127.0.0.1", port), handler), f"http://127.0.0.1:{port}/callback"
207
+
208
+
209
+ def _pkce() -> tuple[str, str]:
210
+ verifier = secrets.token_urlsafe(64)
211
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
212
+ return verifier, challenge
ta/lib/auth_servers.py ADDED
@@ -0,0 +1,56 @@
1
+ import httpx
2
+
3
+ ARC_API_URLS: dict[str, str] = {
4
+ "dev": "https://arc.dev.trustarc.com",
5
+ "qa": "https://arc.qa.trustarc.com",
6
+ "prod-us": "https://arc.trustarc.com",
7
+ "prod-eu": "https://arc.trustarc.eu",
8
+ "prod-india": "https://arc.trustarc.in",
9
+ }
10
+
11
+ AUTH_BASE_URLS: dict[str, str] = {env: f"{url}/api/mcp" for env, url in ARC_API_URLS.items()}
12
+
13
+ FEATURE_MANAGER_API_URLS: dict[str, str] = {
14
+ "dev": "https://feature.dev.trustarc.com",
15
+ "qa": "https://feature.qa.trustarc.com",
16
+ "prod-us": "https://feature.trustarc.com",
17
+ "prod-eu": "https://feature.trustarc.eu",
18
+ "prod-india": "https://feature.trustarc.in",
19
+ }
20
+
21
+ SCOPE = "openid"
22
+
23
+
24
+ def discover(env: str) -> dict:
25
+ base = AUTH_BASE_URLS.get(env)
26
+ if not base:
27
+ raise RuntimeError(f"No auth base URL configured for env '{env}'")
28
+ oauth_resp = httpx.get(f"{base}/.well-known/oauth-authorization-server", timeout=30)
29
+ oauth_resp.raise_for_status()
30
+ oauth_md = oauth_resp.json()
31
+ oidc_resp = httpx.get(f"{base}/.well-known/openid-configuration", timeout=30)
32
+ oidc_md = oidc_resp.json() if oidc_resp.status_code == 200 else {}
33
+ return {
34
+ "registration_endpoint": oauth_md["registration_endpoint"],
35
+ "authorization_endpoint": oauth_md["authorization_endpoint"],
36
+ "token_endpoint": oauth_md["token_endpoint"],
37
+ "revocation_endpoint": oauth_md.get("revocation_endpoint"),
38
+ "userinfo_endpoint": oidc_md.get("userinfo_endpoint"),
39
+ "scope": SCOPE,
40
+ }
41
+
42
+
43
+ def register(registration_endpoint: str, redirect_uri: str) -> str:
44
+ resp = httpx.post(
45
+ registration_endpoint,
46
+ json={"redirect_uris": [redirect_uri]},
47
+ timeout=30,
48
+ )
49
+ resp.raise_for_status()
50
+ return resp.json()["client_id"]
51
+
52
+
53
+ def auth_server_for(env: str, redirect_uri: str) -> dict:
54
+ cfg = discover(env)
55
+ cfg["client_id"] = register(cfg["registration_endpoint"], redirect_uri)
56
+ return cfg
@@ -0,0 +1,72 @@
1
+ import json
2
+ import time
3
+ from pathlib import Path
4
+
5
+ import httpx
6
+
7
+ from ta.lib.auth_servers import ARC_API_URLS
8
+ from ta.lib.urls import to_public_url
9
+
10
+ CATALOG_DIR = Path.home() / ".ta" / "catalog"
11
+ CATALOG_CACHE_TTL_SECONDS = 600
12
+
13
+
14
+ def _catalog_cache_path(env: str) -> Path:
15
+ return CATALOG_DIR / f"{env}.json"
16
+
17
+
18
+ def _read_cache(path: Path) -> dict | None:
19
+ if not path.exists():
20
+ return None
21
+ with open(path) as f:
22
+ return json.load(f)
23
+
24
+
25
+ def _fetch_catalog(env: str) -> dict:
26
+ base = ARC_API_URLS.get(env)
27
+ if not base:
28
+ raise RuntimeError(f"No ARC API URL configured for env '{env}'")
29
+ from ta.lib.auth import get_access_token
30
+
31
+ token = get_access_token(env)
32
+ headers = {"Authorization": f"Bearer {token}"} if token else {}
33
+ resp = httpx.get(f"{base}/api/mcp/services", headers=headers, timeout=30)
34
+ resp.raise_for_status()
35
+ catalog: dict[str, dict] = {}
36
+ for entry in resp.json().get("services", []):
37
+ catalog[entry["key"]] = {
38
+ "name": entry.get("name", ""),
39
+ "description": entry.get("description", ""),
40
+ "base_url": to_public_url(entry.get("base_url")),
41
+ "spec_path": entry["spec_path"],
42
+ "prefix": entry["prefix"],
43
+ "include_tags": entry["include_tags"],
44
+ }
45
+ return catalog
46
+
47
+
48
+ def evict_catalog(env: str) -> None:
49
+ _catalog_cache_path(env).unlink(missing_ok=True)
50
+
51
+
52
+ def load_catalog(env: str, *, force: bool = False) -> dict:
53
+ path = _catalog_cache_path(env)
54
+ if not force and path.exists() and (time.time() - path.stat().st_mtime) < CATALOG_CACHE_TTL_SECONDS:
55
+ cached = _read_cache(path)
56
+ if cached is not None:
57
+ return cached
58
+
59
+ try:
60
+ catalog = _fetch_catalog(env)
61
+ except httpx.HTTPError as e:
62
+ stale = _read_cache(path)
63
+ if stale is not None:
64
+ return stale
65
+ raise RuntimeError(
66
+ f"Could not fetch service catalog for env '{env}' and no cached copy is available: {e}"
67
+ ) from e
68
+
69
+ path.parent.mkdir(parents=True, exist_ok=True)
70
+ with open(path, "w") as f:
71
+ json.dump(catalog, f)
72
+ return catalog