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.
@@ -0,0 +1,39 @@
1
+ from trustarc_sdk.openapi.call import Entrypoint, acall, call
2
+ from trustarc_sdk.openapi.feature_manager import (
3
+ ASK_ARC_CUD_LICENSE_ID,
4
+ ais_feature_enabled,
5
+ is_feature_enabled,
6
+ )
7
+ from trustarc_sdk.openapi.parse import (
8
+ ACTION_VERBS,
9
+ METHOD_ACTION,
10
+ base_url_from_spec,
11
+ detect_namespace_prefix,
12
+ extract_resource_action,
13
+ fetch_spec,
14
+ group_operations_by_resource,
15
+ iter_operations,
16
+ resolve_base_url,
17
+ )
18
+ from trustarc_sdk.openapi.schema import build_responses, build_schema_snapshot, resolve_ref
19
+
20
+ __all__ = [
21
+ "ACTION_VERBS",
22
+ "ASK_ARC_CUD_LICENSE_ID",
23
+ "METHOD_ACTION",
24
+ "Entrypoint",
25
+ "acall",
26
+ "ais_feature_enabled",
27
+ "base_url_from_spec",
28
+ "build_responses",
29
+ "build_schema_snapshot",
30
+ "call",
31
+ "is_feature_enabled",
32
+ "detect_namespace_prefix",
33
+ "extract_resource_action",
34
+ "fetch_spec",
35
+ "group_operations_by_resource",
36
+ "iter_operations",
37
+ "resolve_base_url",
38
+ "resolve_ref",
39
+ ]
@@ -0,0 +1,204 @@
1
+ import re
2
+ from datetime import datetime, timezone
3
+ from enum import StrEnum
4
+ from urllib.parse import quote
5
+
6
+ import httpx
7
+ from trustarc_sdk.openapi.feature_manager import (
8
+ ASK_ARC_CUD_LICENSE_ID,
9
+ ais_feature_enabled,
10
+ is_feature_enabled,
11
+ )
12
+
13
+
14
+ class Entrypoint(StrEnum):
15
+ MCP = "mcp"
16
+ CLI = "cli"
17
+ ASK_ARC = "ask-arc"
18
+
19
+
20
+ _PLACEHOLDER = re.compile(r"\{([^/{}]+)\}")
21
+ _DOT_SEGMENTS = frozenset({".", ".."})
22
+
23
+
24
+ def _encode_header_value(value: str) -> str:
25
+ return quote(value)
26
+
27
+
28
+ def _cud_blocked_response(method: str, url: str) -> httpx.Response:
29
+ return httpx.Response(
30
+ 403,
31
+ json={"error": "Create or Update or Delete action is not enabled for this account."},
32
+ request=httpx.Request(method, url),
33
+ )
34
+
35
+
36
+ def _prepare(
37
+ operation: dict,
38
+ base_url: str,
39
+ access_token: str,
40
+ args: dict | None,
41
+ user_agent: str | None = None,
42
+ title: str | None = None,
43
+ explanation: str | None = None,
44
+ human_approved: bool = False,
45
+ thread_id: str | None = None,
46
+ entrypoint: Entrypoint | None = None,
47
+ source: str | None = None,
48
+ path_params: dict | None = None,
49
+ query_params: dict | None = None,
50
+ body: dict | list | None = None,
51
+ ) -> tuple[str, str, dict, dict | list | None, dict]:
52
+ method = operation["method"]
53
+ if path_params is not None or query_params is not None or body is not None:
54
+ if args:
55
+ raise TypeError("pass either args or path_params/query_params/body, not both")
56
+ path_values = dict(path_params or {})
57
+ query_values = dict(query_params or {})
58
+ body_fields: dict | list | None = body
59
+ else:
60
+ path_values, query_values, body_fields = _classify_by_name(operation, args or {})
61
+ if not body_fields:
62
+ body_fields = None
63
+ if method.upper() == "GET":
64
+ body_fields = None
65
+
66
+ path = _bind_path(operation["path"], path_values)
67
+ url = f"{base_url.rstrip('/')}{path}"
68
+ headers: dict = {}
69
+ if access_token:
70
+ headers["Authorization"] = f"Bearer {access_token}"
71
+ if user_agent:
72
+ headers["User-Agent"] = user_agent
73
+ if entrypoint:
74
+ headers["X-TrustArc-Entrypoint"] = entrypoint
75
+ if source:
76
+ headers["X-TrustArc-Source"] = source
77
+ if title:
78
+ headers["X-TrustArc-Title"] = _encode_header_value(title)
79
+ if explanation:
80
+ headers["X-TrustArc-Explanation"] = _encode_header_value(explanation)
81
+ if human_approved:
82
+ headers["X-TrustArc-Human-Approved"] = "true"
83
+ if thread_id:
84
+ headers["X-TrustArc-Thread-Id"] = thread_id
85
+ headers["X-TrustArc-Request-Time"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
86
+ return method, url, query_values, body_fields, headers
87
+
88
+
89
+ def _classify_by_name(operation: dict, args: dict) -> tuple[dict, dict, dict]:
90
+ path_values: dict = {}
91
+ query_values: dict = {}
92
+ body_fields: dict = {}
93
+ declared = {p["name"]: p for p in operation.get("parameters", [])}
94
+ for key, value in args.items():
95
+ param = declared.get(key)
96
+ if param and param.get("in") == "path":
97
+ path_values[key] = value
98
+ elif param and param.get("in") == "query":
99
+ query_values[key] = value
100
+ else:
101
+ body_fields[key] = value
102
+ return path_values, query_values, body_fields
103
+
104
+
105
+ def _bind_path(template: str, path_values: dict) -> str:
106
+ path = template
107
+ for name, value in path_values.items():
108
+ segment = str(value)
109
+ if segment in _DOT_SEGMENTS:
110
+ raise ValueError(f"path parameter {name} cannot be {segment!r}")
111
+ path = path.replace(f"{{{name}}}", quote(segment, safe=""))
112
+ missing = _PLACEHOLDER.findall(path)
113
+ if missing:
114
+ raise ValueError(f"missing path parameter(s): {', '.join(missing)}")
115
+ return path
116
+
117
+
118
+ def call(
119
+ operation: dict,
120
+ base_url: str,
121
+ access_token: str,
122
+ args: dict | None = None,
123
+ timeout: float = 60.0,
124
+ user_agent: str | None = None,
125
+ title: str | None = None,
126
+ explanation: str | None = None,
127
+ human_approved: bool = False,
128
+ thread_id: str | None = None,
129
+ entrypoint: Entrypoint | None = None,
130
+ source: str | None = None,
131
+ feature_manager_url: str | None = None,
132
+ feature_id: str = ASK_ARC_CUD_LICENSE_ID,
133
+ path_params: dict | None = None,
134
+ query_params: dict | None = None,
135
+ body: dict | list | None = None,
136
+ ) -> httpx.Response:
137
+ method, url, query_params, body_fields, headers = _prepare(
138
+ operation,
139
+ base_url,
140
+ access_token,
141
+ args,
142
+ user_agent,
143
+ title,
144
+ explanation,
145
+ human_approved,
146
+ thread_id,
147
+ entrypoint,
148
+ source,
149
+ path_params=path_params,
150
+ query_params=query_params,
151
+ body=body,
152
+ )
153
+ if (
154
+ method.upper() != "GET"
155
+ and feature_manager_url
156
+ and not is_feature_enabled(feature_manager_url, feature_id, access_token)
157
+ ):
158
+ return _cud_blocked_response(method, url)
159
+ return httpx.request(method, url, params=query_params or None, json=body_fields, headers=headers, timeout=timeout)
160
+
161
+
162
+ async def acall(
163
+ operation: dict,
164
+ base_url: str,
165
+ access_token: str,
166
+ args: dict | None = None,
167
+ timeout: float = 60.0,
168
+ user_agent: str | None = None,
169
+ title: str | None = None,
170
+ explanation: str | None = None,
171
+ human_approved: bool = False,
172
+ thread_id: str | None = None,
173
+ entrypoint: Entrypoint | None = None,
174
+ source: str | None = None,
175
+ feature_manager_url: str | None = None,
176
+ feature_id: str = ASK_ARC_CUD_LICENSE_ID,
177
+ path_params: dict | None = None,
178
+ query_params: dict | None = None,
179
+ body: dict | list | None = None,
180
+ ) -> httpx.Response:
181
+ method, url, query_params, body_fields, headers = _prepare(
182
+ operation,
183
+ base_url,
184
+ access_token,
185
+ args,
186
+ user_agent,
187
+ title,
188
+ explanation,
189
+ human_approved,
190
+ thread_id,
191
+ entrypoint,
192
+ source,
193
+ path_params=path_params,
194
+ query_params=query_params,
195
+ body=body,
196
+ )
197
+ if (
198
+ method.upper() != "GET"
199
+ and feature_manager_url
200
+ and not await ais_feature_enabled(feature_manager_url, feature_id, access_token)
201
+ ):
202
+ return _cud_blocked_response(method, url)
203
+ async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
204
+ return await client.request(method, url, params=query_params or None, json=body_fields, headers=headers)
@@ -0,0 +1,106 @@
1
+ import base64
2
+ import json
3
+ import logging
4
+ import time
5
+
6
+ import httpx
7
+
8
+ ASK_ARC_CUD_LICENSE_ID = "ARC_ASK_ARC_CUD"
9
+
10
+ _DEFAULT_TTL_S = 5 * 60
11
+ _cache: dict[tuple[str, str, str], tuple[float, bool]] = {}
12
+
13
+
14
+ def _account_key(access_token: str) -> str:
15
+ try:
16
+ payload = access_token.split(".")[1]
17
+ payload += "=" * (-len(payload) % 4)
18
+ claims = json.loads(base64.urlsafe_b64decode(payload))
19
+ return str(claims.get("accountId") or claims.get("account_id") or access_token)
20
+ except Exception:
21
+ return access_token
22
+
23
+
24
+ def _cache_key(feature_manager_url: str, feature_id: str, access_token: str) -> tuple[str, str, str]:
25
+ return (feature_manager_url, feature_id, _account_key(access_token))
26
+
27
+
28
+ def _cache_get(key: tuple[str, str, str]) -> bool | None:
29
+ hit = _cache.get(key)
30
+ if hit is None:
31
+ return None
32
+ if hit[0] > time.monotonic():
33
+ return hit[1]
34
+ del _cache[key]
35
+ return None
36
+
37
+
38
+ def _cache_set(key: tuple[str, str, str], value: bool, ttl_s: float) -> None:
39
+ _cache[key] = (time.monotonic() + ttl_s, value)
40
+
41
+
42
+ def _url(feature_manager_url: str, feature_id: str) -> str:
43
+ return f"{feature_manager_url.rstrip('/')}/api/features/{feature_id}"
44
+
45
+
46
+ def _parse(data: object, feature_id: str) -> bool:
47
+ if not isinstance(data, dict):
48
+ return False
49
+ return str(data.get(feature_id, "")).lower() == "true"
50
+
51
+
52
+ def is_feature_enabled(
53
+ feature_manager_url: str,
54
+ feature_id: str,
55
+ access_token: str,
56
+ ttl_s: float = _DEFAULT_TTL_S,
57
+ timeout: float = 10.0,
58
+ ) -> bool:
59
+ if not feature_manager_url or not access_token:
60
+ return False
61
+ key = _cache_key(feature_manager_url, feature_id, access_token)
62
+ cached = _cache_get(key)
63
+ if cached is not None:
64
+ return cached
65
+ try:
66
+ resp = httpx.get(
67
+ _url(feature_manager_url, feature_id), headers={"Authorization": f"Bearer {access_token}"}, timeout=timeout
68
+ )
69
+ if resp.status_code != 200:
70
+ logging.warning("feature_manager %s -> %s", feature_id, resp.status_code)
71
+ return False
72
+ enabled = _parse(resp.json(), feature_id)
73
+ except Exception as e:
74
+ logging.warning("feature_manager lookup failed for %s: %s", feature_id, e)
75
+ return False
76
+ _cache_set(key, enabled, ttl_s)
77
+ return enabled
78
+
79
+
80
+ async def ais_feature_enabled(
81
+ feature_manager_url: str,
82
+ feature_id: str,
83
+ access_token: str,
84
+ ttl_s: float = _DEFAULT_TTL_S,
85
+ timeout: float = 10.0,
86
+ ) -> bool:
87
+ if not feature_manager_url or not access_token:
88
+ return False
89
+ key = _cache_key(feature_manager_url, feature_id, access_token)
90
+ cached = _cache_get(key)
91
+ if cached is not None:
92
+ return cached
93
+ try:
94
+ async with httpx.AsyncClient(timeout=timeout) as client:
95
+ resp = await client.get(
96
+ _url(feature_manager_url, feature_id), headers={"Authorization": f"Bearer {access_token}"}
97
+ )
98
+ if resp.status_code != 200:
99
+ logging.warning("feature_manager %s -> %s", feature_id, resp.status_code)
100
+ return False
101
+ enabled = _parse(resp.json(), feature_id)
102
+ except Exception as e:
103
+ logging.warning("feature_manager lookup failed for %s: %s", feature_id, e)
104
+ return False
105
+ _cache_set(key, enabled, ttl_s)
106
+ return enabled
@@ -0,0 +1,207 @@
1
+ from urllib.parse import urlparse
2
+
3
+ import httpx
4
+ import inflection
5
+ from trustarc_sdk.openapi.feature_manager import ASK_ARC_CUD_LICENSE_ID, is_feature_enabled
6
+
7
+ ACTION_VERBS = {
8
+ "new",
9
+ "create",
10
+ "update",
11
+ "patch",
12
+ "upsert",
13
+ "merge",
14
+ "delete",
15
+ "approve",
16
+ "reject",
17
+ "submit",
18
+ "cancel",
19
+ "complete",
20
+ "activate",
21
+ "deactivate",
22
+ "enable",
23
+ "disable",
24
+ "start",
25
+ "stop",
26
+ "pause",
27
+ "resume",
28
+ "publish",
29
+ "unpublish",
30
+ "lock",
31
+ "unlock",
32
+ "send",
33
+ "trigger",
34
+ "validate",
35
+ "verify",
36
+ "clone",
37
+ "duplicate",
38
+ "import",
39
+ "export",
40
+ "reset",
41
+ "refresh",
42
+ }
43
+
44
+ METHOD_ACTION = {
45
+ "POST": "create",
46
+ "PUT": "update",
47
+ "PATCH": "patch",
48
+ "DELETE": "delete",
49
+ }
50
+
51
+
52
+ def base_url_from_spec(spec_url: str) -> str:
53
+ parsed = urlparse(spec_url)
54
+ return f"{parsed.scheme}://{parsed.netloc}"
55
+
56
+
57
+ def resolve_base_url(spec: dict, spec_url: str) -> str:
58
+ servers = spec.get("servers") or []
59
+ if servers and servers[0].get("url"):
60
+ url = servers[0]["url"]
61
+ if url.startswith("http"):
62
+ return url.rstrip("/")
63
+ return base_url_from_spec(spec_url).rstrip("/") + "/" + url.lstrip("/")
64
+ return base_url_from_spec(spec_url)
65
+
66
+
67
+ def fetch_spec(spec_url: str) -> dict:
68
+ response = httpx.get(spec_url, follow_redirects=True, timeout=30)
69
+ response.raise_for_status()
70
+ return response.json()
71
+
72
+
73
+ def iter_operations(spec: dict):
74
+ for path, methods in spec.get("paths", {}).items():
75
+ for method, op in methods.items():
76
+ if method.lower() not in {"get", "post", "put", "patch", "delete"}:
77
+ continue
78
+ wire_method = method.upper()
79
+ semantic = (op.get("x-semantic-method") or wire_method).upper()
80
+ yield {
81
+ "method": wire_method,
82
+ "semantic_method": semantic,
83
+ "path": path,
84
+ "summary": op.get("summary", ""),
85
+ "description": op.get("description", ""),
86
+ "parameters": op.get("parameters", []),
87
+ "request_body": op.get("requestBody"),
88
+ "responses": op.get("responses") or {},
89
+ "tags": op.get("tags") or [],
90
+ }
91
+
92
+
93
+ def group_operations_by_resource(
94
+ spec: dict,
95
+ namespace_prefix: list[str] | None = None,
96
+ include_tags: list[str] | None = None,
97
+ *,
98
+ feature_manager_url: str | None = None,
99
+ access_token: str | None = None,
100
+ ) -> dict[str, list[tuple[str, dict]]]:
101
+ prefix = namespace_prefix if namespace_prefix is not None else detect_namespace_prefix(spec)
102
+ tag_filter = set(include_tags) if include_tags else None
103
+ cud_enabled = not feature_manager_url or is_feature_enabled(
104
+ feature_manager_url, ASK_ARC_CUD_LICENSE_ID, access_token or ""
105
+ )
106
+ grouped: dict[str, list[tuple[str, dict]]] = {}
107
+ seen: dict[tuple[str, str], int] = {}
108
+ for op in iter_operations(spec):
109
+ if tag_filter and not tag_filter.intersection(op.get("tags") or []):
110
+ continue
111
+ if not cud_enabled and op["method"].upper() != "GET":
112
+ continue
113
+ resource, action = extract_resource_action(op, namespace_prefix=prefix)
114
+ key = (resource, action)
115
+ seen[key] = seen.get(key, 0) + 1
116
+ if seen[key] > 1:
117
+ action = f"{action}-{seen[key]}"
118
+ grouped.setdefault(resource, []).append((action, op))
119
+ return grouped
120
+
121
+
122
+ def detect_namespace_prefix(spec: dict) -> list[str]:
123
+ paths = list(spec.get("paths", {}).keys())
124
+ if len(paths) < 2:
125
+ return []
126
+ firsts = set()
127
+ for p in paths:
128
+ segs = [s for s in p.split("/") if s]
129
+ if not segs or segs[0].startswith("{"):
130
+ return []
131
+ firsts.add(segs[0])
132
+ return [firsts.pop()] if len(firsts) == 1 else []
133
+
134
+
135
+ def extract_resource_action(op: dict, namespace_prefix: list[str] | None = None) -> tuple[str, str]:
136
+ method = op.get("semantic_method", op["method"]).upper()
137
+ raw = [s for s in op["path"].split("/") if s]
138
+
139
+ segs = list(raw)
140
+ for p in namespace_prefix or []:
141
+ if segs and segs[0].lower() == p.lower():
142
+ segs.pop(0)
143
+ else:
144
+ break
145
+
146
+ resource_parts: list[str] = []
147
+ explicit_action: str | None = None
148
+ trailing_param = False
149
+
150
+ i = 0
151
+ while i < len(segs):
152
+ seg = segs[i]
153
+ lower = seg.lower()
154
+
155
+ if seg.startswith("{"):
156
+ trailing_param = True
157
+ i += 1
158
+ continue
159
+
160
+ trailing_param = False
161
+
162
+ if lower in ACTION_VERBS and all(segs[j].startswith("{") for j in range(i + 1, len(segs))):
163
+ explicit_action = lower
164
+ trailing_param = any(segs[j].startswith("{") for j in range(i + 1, len(segs)))
165
+ i = len(segs)
166
+ break
167
+
168
+ resource_parts.append(lower)
169
+ i += 1
170
+
171
+ resource_words: list[str] = []
172
+ for part in resource_parts:
173
+ words = _split_words(part)
174
+ if words:
175
+ words[-1] = inflection.singularize(words[-1])
176
+ resource_words.extend(words)
177
+ resource = "-".join(resource_words) or "root"
178
+
179
+ if explicit_action and not trailing_param:
180
+ action = explicit_action
181
+ elif method == "GET":
182
+ action = "get" if trailing_param else "list"
183
+ else:
184
+ action = METHOD_ACTION.get(method, method.lower())
185
+
186
+ return resource, action
187
+
188
+
189
+ def _split_words(name: str) -> list[str]:
190
+ tokens = name.replace("_", " ").replace("-", " ")
191
+ words: list[str] = []
192
+ buf: list[str] = []
193
+ for ch in tokens:
194
+ if ch == " ":
195
+ if buf:
196
+ words.append("".join(buf))
197
+ buf = []
198
+ elif ch.isupper():
199
+ if buf:
200
+ words.append("".join(buf))
201
+ buf = []
202
+ buf.append(ch.lower())
203
+ else:
204
+ buf.append(ch)
205
+ if buf:
206
+ words.append("".join(buf))
207
+ return [w for w in words if w]
@@ -0,0 +1,92 @@
1
+ def resolve_ref(node, spec: dict, seen: set[str] | None = None):
2
+ seen = seen if seen is not None else set()
3
+ if isinstance(node, dict):
4
+ if "$ref" in node and isinstance(node["$ref"], str):
5
+ ref = node["$ref"]
6
+ if ref in seen:
7
+ return {"$ref": ref}
8
+ if ref.startswith("#/"):
9
+ parts = ref[2:].split("/")
10
+ target = spec
11
+ for p in parts:
12
+ target = target.get(p, {}) if isinstance(target, dict) else {}
13
+ return resolve_ref(target, spec, seen | {ref})
14
+ return {"$ref": ref}
15
+ return {k: resolve_ref(v, spec, seen) for k, v in node.items()}
16
+ if isinstance(node, list):
17
+ return [resolve_ref(v, spec, seen) for v in node]
18
+ return node
19
+
20
+
21
+ JSON_MIME = "application/json"
22
+
23
+
24
+ def _preferred_mime(content: dict) -> str | None:
25
+ offered = [mime for mime, media in content.items() if "schema" in media]
26
+ for mime in offered:
27
+ if mime.split(";")[0].strip() == JSON_MIME:
28
+ return mime
29
+ return offered[0] if offered else None
30
+
31
+
32
+ def _success_codes(responses: dict) -> list[str]:
33
+ exact = sorted(c for c in responses if c.isdigit() and c.startswith("2"))
34
+ return exact + [c for c in ("2XX", "default") if c in responses]
35
+
36
+
37
+ def _is_success(code: str) -> bool:
38
+ return code.startswith("2")
39
+
40
+
41
+ def build_responses(op: dict, spec: dict) -> dict:
42
+ """Every documented status with its description; 2xx entries also carry the resolved schema."""
43
+ responses: dict = {}
44
+ for code, response in (op.get("responses") or {}).items():
45
+ code = str(code)
46
+ if code.startswith("x-"):
47
+ continue
48
+ response = resolve_ref(response or {}, spec)
49
+ entry: dict = {"description": response.get("description") or ""}
50
+ if _is_success(code):
51
+ content = response.get("content") or {}
52
+ mime = _preferred_mime(content)
53
+ if mime:
54
+ entry["content_type"] = mime
55
+ entry["schema"] = resolve_ref(content[mime]["schema"], spec)
56
+ responses[code] = entry
57
+ return responses
58
+
59
+
60
+ def build_schema_snapshot(op: dict, spec: dict) -> dict:
61
+ snapshot: dict = {
62
+ "method": op["method"],
63
+ "path": op["path"],
64
+ "summary": op.get("summary") or "",
65
+ "description": op.get("description") or "",
66
+ "parameters": [resolve_ref(p, spec) for p in op.get("parameters") or []],
67
+ }
68
+ body = op.get("request_body")
69
+ if body:
70
+ content = body.get("content") or {}
71
+ mime = _preferred_mime(content)
72
+ if mime:
73
+ snapshot["request_body"] = {
74
+ "content_type": mime,
75
+ "required": body.get("required", False),
76
+ "schema": resolve_ref(content[mime]["schema"], spec),
77
+ }
78
+ responses = {str(c): r for c, r in (op.get("responses") or {}).items()}
79
+ for code in _success_codes(responses):
80
+ content = responses[code].get("content") or {}
81
+ for mime, media in content.items():
82
+ if "schema" in media:
83
+ snapshot["response"] = {
84
+ "status": code,
85
+ "content_type": mime,
86
+ "schema": resolve_ref(media["schema"], spec),
87
+ }
88
+ break
89
+ if "response" in snapshot:
90
+ break
91
+ snapshot["responses"] = build_responses(op, spec)
92
+ return snapshot