xyfra-sdk 1.0.0__tar.gz

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,137 @@
1
+ # =========================
2
+ # .NET / C# Build Artifacts
3
+ # =========================
4
+ bin/
5
+ obj/
6
+ [Dd]ebug/
7
+ [Rr]elease/
8
+ x64/
9
+ x86/
10
+ *.dll
11
+ *.exe
12
+ *.pdb
13
+ *.user
14
+ *.userosscache
15
+ *.sln.docstates
16
+
17
+ # FEAT-EXPR-ACCEL v1 Bundle C — BenchmarkDotNet output (generated
18
+ # locally by `dotnet run -c Release --project ...Tests.Bench`).
19
+ # Not source-controlled; engineers regenerate per local hardware.
20
+ BenchmarkDotNet.Artifacts/
21
+
22
+ # NuGet Packages
23
+ *.nupkg
24
+ **/packages/*
25
+ !**/packages/build/
26
+
27
+ # =========================
28
+ # Node.js / Next.js
29
+ # =========================
30
+ node_modules/
31
+ .next/
32
+ out/
33
+ .pnpm-debug.log*
34
+ npm-debug.log*
35
+ yarn-debug.log*
36
+ yarn-error.log*
37
+
38
+ # =========================
39
+ # IDE / Editor Files
40
+ # =========================
41
+ .idea/
42
+ .vscode/
43
+ *.swp
44
+ *.swo
45
+ *~
46
+ .vs/
47
+
48
+ # =========================
49
+ # Environment & Secrets
50
+ # =========================
51
+ .env
52
+ .env.local
53
+ .env.development.local
54
+ .env.test.local
55
+ .env.production.local
56
+ *.env
57
+ !.env.example
58
+ appsettings.*.local.json
59
+
60
+ # Secrets baseline (for detect-secrets pre-commit hook)
61
+ .secrets.baseline
62
+
63
+ # =========================
64
+ # Logs
65
+ # =========================
66
+ logs/
67
+ *.log
68
+
69
+ # =========================
70
+ # Test Results & Coverage
71
+ # =========================
72
+ TestResults/
73
+ test-results/
74
+ coverage/
75
+ *.trx
76
+ *.coverage
77
+ *.coveragexml
78
+
79
+ # =========================
80
+ # OS Generated Files
81
+ # =========================
82
+ .DS_Store
83
+ .DS_Store?
84
+ ._*
85
+ Thumbs.db
86
+ ehthumbs.db
87
+ Desktop.ini
88
+
89
+ # =========================
90
+ # Backup Files
91
+ # =========================
92
+ docs_backup_*/
93
+ *.bak
94
+ *.backup
95
+
96
+ # =========================
97
+ # Process ID Files
98
+ # =========================
99
+ .pids/
100
+
101
+ # =========================
102
+ # Misc
103
+ # =========================
104
+ *.tmp
105
+ *.temp
106
+ *.cache
107
+
108
+
109
+ # IDE / agent working directories
110
+ .claude/
111
+ # .cursor/* ignores immediate children (mcp.json, etc.) without blocking git
112
+ # from descending into subdirectories. The negation rules below then
113
+ # selectively re-include team-facing config (skills + rules).
114
+ .cursor/*
115
+ !.cursor/skills/
116
+ !.cursor/skills/**
117
+ !.cursor/rules/
118
+ !.cursor/rules/**
119
+
120
+ # Python bytecode (added 2026-05-09 — was missing; tests/aichat-schema-recovery/__pycache__/ had been silently tracked)
121
+ __pycache__/
122
+ *.pyc
123
+ *.pyo
124
+
125
+ # Build/export artifacts (added 2026-05-17)
126
+ ai-sdlc-bundle.zip
127
+
128
+ # FEAT-PARTNER-SURFACE Phase P4b SDK artefacts
129
+ src/sdk/typescript/node_modules/
130
+ src/sdk/typescript/dist/
131
+ src/sdk/typescript/*.tgz
132
+ src/sdk/python/.venv/
133
+ src/sdk/python/venv/
134
+ src/sdk/python/dist/
135
+ src/sdk/python/build/
136
+ src/sdk/python/*.egg-info/
137
+ src/sdk/python/__pycache__/
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: xyfra-sdk
3
+ Version: 1.0.0
4
+ Summary: Xyfra Data Platform partner SDK (Python)
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: httpx>=0.27.0
8
+ Requires-Dist: pydantic>=2.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: ariadne-codegen>=0.13.0; extra == 'dev'
11
+ Requires-Dist: datamodel-code-generator>=0.25.0; extra == 'dev'
12
+ Requires-Dist: openapi-python-client>=0.21.0; extra == 'dev'
13
+ Requires-Dist: pytest>=8.0; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # xyfra-sdk
17
+
18
+ Python SDK for the Xyfra Data Platform partner surface.
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ pip install xyfra-sdk
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```python
29
+ import asyncio
30
+ from xyfra_sdk import XyfraClient, XyfraClientOptions
31
+
32
+ async def main():
33
+ client = XyfraClient(XyfraClientOptions(
34
+ base_url="https://api.xyfra.ai",
35
+ refresh_token="xyfra-rt-...",
36
+ tenant_id="tenant-123",
37
+ ))
38
+ await client.refresh()
39
+ result = await client.call_tool("semantic.query", {"query": "..."})
40
+ print(result)
41
+
42
+ asyncio.run(main())
43
+ ```
44
+
45
+ ## Codegen
46
+
47
+ The `scripts/` directory contains generators that consume the partner OpenAPI,
48
+ GraphQL SDL, and MCP schema artefacts and emit typed clients under
49
+ `xyfra_sdk/client/`.
@@ -0,0 +1,34 @@
1
+ # xyfra-sdk
2
+
3
+ Python SDK for the Xyfra Data Platform partner surface.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install xyfra-sdk
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ import asyncio
15
+ from xyfra_sdk import XyfraClient, XyfraClientOptions
16
+
17
+ async def main():
18
+ client = XyfraClient(XyfraClientOptions(
19
+ base_url="https://api.xyfra.ai",
20
+ refresh_token="xyfra-rt-...",
21
+ tenant_id="tenant-123",
22
+ ))
23
+ await client.refresh()
24
+ result = await client.call_tool("semantic.query", {"query": "..."})
25
+ print(result)
26
+
27
+ asyncio.run(main())
28
+ ```
29
+
30
+ ## Codegen
31
+
32
+ The `scripts/` directory contains generators that consume the partner OpenAPI,
33
+ GraphQL SDL, and MCP schema artefacts and emit typed clients under
34
+ `xyfra_sdk/client/`.
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "xyfra-sdk"
7
+ version = "1.0.0"
8
+ description = "Xyfra Data Platform partner SDK (Python)"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "httpx>=0.27.0",
14
+ "pydantic>=2.0",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ dev = [
19
+ "pytest>=8.0",
20
+ "openapi-python-client>=0.21.0",
21
+ "ariadne-codegen>=0.13.0",
22
+ "datamodel-code-generator>=0.25.0",
23
+ ]
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["xyfra_sdk"]
27
+
28
+ [tool.pytest.ini_options]
29
+ testpaths = ["tests"]
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env python3
2
+ """Generate the GraphQL typed operations client from the partner SDL artefact."""
3
+ import asyncio
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ import httpx
10
+
11
+
12
+ def main() -> None:
13
+ base_url = os.environ.get("XYFRA_SDK_BASE_URL", "http://localhost:5000")
14
+ tenant_id = os.environ.get("XYFRA_SDK_TENANT_ID", "test-tenant")
15
+ version = os.environ.get("XYFRA_SDK_API_VERSION", "v1")
16
+ refresh_token = os.environ.get("XYFRA_CI_SANDBOX_REFRESH_TOKEN", "")
17
+
18
+ out_dir = Path(__file__).parent.parent / "xyfra_sdk" / "client" / "graphql"
19
+
20
+ if not refresh_token:
21
+ raise RuntimeError("XYFRA_CI_SANDBOX_REFRESH_TOKEN is required to fetch the partner GraphQL SDL artefact")
22
+
23
+ # Refresh to an access JWT before fetching the partner-gated endpoint.
24
+ from xyfra_sdk.auth import refresh_access_token
25
+
26
+ tokens = asyncio.run(refresh_access_token(base_url, refresh_token))
27
+ sdl_url = f"{base_url}/api/_meta/graphql/schema.graphql?tenant_id={tenant_id}&version={version}"
28
+
29
+ response = httpx.get(
30
+ sdl_url,
31
+ headers={"Authorization": f"Bearer {tokens['accessToken']}", "Accept": "text/plain"},
32
+ )
33
+ response.raise_for_status()
34
+
35
+ # ariadne-codegen emits a Python package from the SDL into the target path.
36
+ if out_dir.exists():
37
+ shutil.rmtree(out_dir)
38
+ out_dir.mkdir(parents=True, exist_ok=True)
39
+
40
+ schema_path = out_dir / "schema.graphql"
41
+ schema_path.write_text(response.text)
42
+
43
+ subprocess.run(
44
+ [
45
+ "ariadne-codegen",
46
+ "--schema",
47
+ str(schema_path),
48
+ "--target-package-path",
49
+ str(out_dir),
50
+ ],
51
+ check=True,
52
+ )
53
+
54
+ print(f"Generated GraphQL client from {sdl_url} -> {out_dir}")
55
+
56
+
57
+ if __name__ == "__main__":
58
+ main()
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env python3
2
+ """Generate typed MCP tool-input Pydantic models from the partner-projected schema dump."""
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import re
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ import httpx
11
+
12
+
13
+ def main() -> None:
14
+ base_url = os.environ.get("XYFRA_SDK_BASE_URL", "http://localhost:5000")
15
+ refresh_token = os.environ.get("XYFRA_CI_SANDBOX_REFRESH_TOKEN", "")
16
+
17
+ list_url = f"{base_url}/mcp/list_tools_with_schemas"
18
+ out_dir = Path(__file__).parent.parent / "xyfra_sdk" / "client" / "mcp"
19
+ out_dir.mkdir(parents=True, exist_ok=True)
20
+
21
+ if not refresh_token:
22
+ raise RuntimeError("XYFRA_CI_SANDBOX_REFRESH_TOKEN is required to fetch the partner MCP schema artefact")
23
+
24
+ # Refresh to an access JWT before fetching the partner-gated endpoint.
25
+ from xyfra_sdk.auth import refresh_access_token
26
+
27
+ tokens = asyncio.run(refresh_access_token(base_url, refresh_token))
28
+
29
+ response = httpx.get(list_url, headers={"Authorization": f"Bearer {tokens['accessToken']}"})
30
+ response.raise_for_status()
31
+ envelope = response.json()
32
+ if not envelope.get("ok") or "data" not in envelope:
33
+ raise RuntimeError(envelope.get("error", {}).get("code", "mcp_schema_fetch_failed"))
34
+
35
+ init_lines = []
36
+ for tool in envelope["data"]["tools"]:
37
+ safe_name = re.sub(r"[^a-zA-Z0-9_]", "_", tool["name"])
38
+ schema = {
39
+ "$schema": "http://json-schema.org/draft-07/schema#",
40
+ "title": f"{safe_name}_input",
41
+ **tool["inputSchema"],
42
+ }
43
+ schema_file = out_dir / f"{safe_name}.schema.json"
44
+ schema_file.write_text(json.dumps(schema, indent=2))
45
+
46
+ model_file = out_dir / f"{safe_name}.py"
47
+ subprocess.run(
48
+ [
49
+ "datamodel-codegen",
50
+ "--input",
51
+ str(schema_file),
52
+ "--input-file-type",
53
+ "jsonschema",
54
+ "--output",
55
+ str(model_file),
56
+ "--class-name",
57
+ f"{safe_name}_input",
58
+ ],
59
+ check=True,
60
+ )
61
+ init_lines.append(f"from .{safe_name} import {safe_name}_input")
62
+
63
+ (out_dir / "__init__.py").write_text("\n".join(init_lines) + "\n")
64
+ print(f"Generated MCP client from {list_url} -> {out_dir}")
65
+
66
+
67
+ if __name__ == "__main__":
68
+ main()
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env python3
2
+ """Generate the REST client module from the partner OpenAPI artefact."""
3
+ import asyncio
4
+ import os
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+
10
+
11
+ def main() -> None:
12
+ base_url = os.environ.get("XYFRA_SDK_BASE_URL", "http://localhost:5000")
13
+ version = os.environ.get("XYFRA_SDK_API_VERSION", "v1")
14
+ refresh_token = os.environ.get("XYFRA_CI_SANDBOX_REFRESH_TOKEN", "")
15
+
16
+ out_dir = Path(__file__).parent.parent / "xyfra_sdk" / "client" / "rest"
17
+ out_dir.mkdir(parents=True, exist_ok=True)
18
+
19
+ if not refresh_token:
20
+ raise RuntimeError("XYFRA_CI_SANDBOX_REFRESH_TOKEN is required to fetch the partner OpenAPI artefact")
21
+
22
+ # Refresh to an access JWT before fetching the partner-gated endpoint.
23
+ from xyfra_sdk.auth import refresh_access_token
24
+
25
+ tokens = asyncio.run(refresh_access_token(base_url, refresh_token))
26
+ spec_url = f"{base_url}/api/_meta/openapi.json?version={version}"
27
+
28
+ response = httpx.get(spec_url, headers={"Authorization": f"Bearer {tokens['accessToken']}"})
29
+ response.raise_for_status()
30
+
31
+ spec_path = out_dir / "openapi.json"
32
+ spec_path.write_text(response.text)
33
+
34
+ subprocess.run(
35
+ [
36
+ "openapi-python-client",
37
+ "generate",
38
+ "--path",
39
+ str(spec_path),
40
+ "--meta",
41
+ "none",
42
+ "--output-path",
43
+ str(out_dir),
44
+ ],
45
+ check=True,
46
+ )
47
+ print(f"Generated REST client from {spec_url} -> {out_dir}")
48
+
49
+
50
+ if __name__ == "__main__":
51
+ main()
@@ -0,0 +1,14 @@
1
+ import pytest
2
+
3
+ from xyfra_sdk import XyfraClient, XyfraClientOptions
4
+
5
+
6
+ def test_client_init() -> None:
7
+ client = XyfraClient(
8
+ XyfraClientOptions(
9
+ base_url="https://api.xyfra.ai/",
10
+ refresh_token="rt-test",
11
+ tenant_id="t-test",
12
+ )
13
+ )
14
+ assert client is not None
@@ -0,0 +1,4 @@
1
+ from .client import XyfraClient, XyfraClientOptions
2
+ from .auth import refresh_access_token
3
+
4
+ __all__ = ["XyfraClient", "XyfraClientOptions", "refresh_access_token"]
@@ -0,0 +1,20 @@
1
+ import os
2
+ from typing import Any
3
+
4
+ import httpx
5
+
6
+
7
+ async def refresh_access_token(
8
+ base_url: str,
9
+ refresh_token: str,
10
+ ) -> dict[str, Any]:
11
+ """Refresh an access JWT from a partner refresh token."""
12
+ response = await httpx.AsyncClient().post(
13
+ f"{base_url}/api/partners/me/credentials/refresh",
14
+ headers={"Authorization": f"Bearer {refresh_token}"},
15
+ )
16
+ response.raise_for_status()
17
+ envelope = response.json()
18
+ if not envelope.get("ok") or "data" not in envelope:
19
+ raise RuntimeError(envelope.get("error", {}).get("code", "refresh_failed"))
20
+ return envelope["data"]
@@ -0,0 +1,273 @@
1
+ from dataclasses import dataclass, field
2
+ import hashlib
3
+ import hmac
4
+ import uuid
5
+ from typing import Any, Callable, Optional
6
+
7
+ import httpx
8
+
9
+ from .auth import refresh_access_token
10
+
11
+
12
+ @dataclass
13
+ class XyfraClientOptions:
14
+ base_url: str
15
+ refresh_token: str
16
+ tenant_id: str
17
+ sandbox_deterministic: bool = False
18
+ traceparent: Optional[str] = None
19
+
20
+
21
+ class _ToolsNamespace:
22
+ def __init__(self, client: "XyfraClient") -> None:
23
+ self._client = client
24
+
25
+ async def call(self, tool_name: str, arguments: dict[str, Any]) -> Any:
26
+ return await self._client._post("/mcp/call_tool", {"tool_name": tool_name, "arguments": arguments})
27
+
28
+
29
+ class _EventsNamespace:
30
+ def __init__(self, client: "XyfraClient") -> None:
31
+ self._client = client
32
+
33
+ async def subscribe(
34
+ self,
35
+ surface: str,
36
+ on_event: Callable[[dict[str, Any]], None],
37
+ *,
38
+ since: Optional[str] = None,
39
+ signal: Any = None,
40
+ ) -> None:
41
+ params: dict[str, Any] = {"surface": surface}
42
+ if since:
43
+ params["since"] = since
44
+ headers = await self._client.headers()
45
+ headers["Accept"] = "text/event-stream"
46
+ async with self._client._http.stream(
47
+ "GET",
48
+ f"{self._client._base_url}/api/events",
49
+ headers=headers,
50
+ params=params,
51
+ ) as response:
52
+ response.raise_for_status()
53
+ buffer = ""
54
+ last_event_id: Optional[str] = None
55
+ event_name: Optional[str] = None
56
+ async for chunk in response.aiter_text():
57
+ buffer += chunk
58
+ lines = buffer.split("\n")
59
+ buffer = lines.pop()
60
+ for line in lines:
61
+ if line.startswith("id:"):
62
+ last_event_id = line[3:].strip()
63
+ elif line.startswith("event:"):
64
+ event_name = line[6:].strip()
65
+ elif line.startswith("data:"):
66
+ raw = line[5:].strip()
67
+ try:
68
+ import json
69
+
70
+ data = json.loads(raw)
71
+ except Exception:
72
+ data = raw
73
+ on_event({"id": last_event_id, "event": event_name, "data": data})
74
+ elif line.strip() == "":
75
+ event_name = None
76
+
77
+
78
+ class _WebhooksNamespace:
79
+ def __init__(self, client: "XyfraClient") -> None:
80
+ self._client = client
81
+
82
+ def verify_signature(self, payload: str, signature: str, secret: str) -> bool:
83
+ expected = hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
84
+ return hmac.compare_digest(signature, expected)
85
+
86
+
87
+ class _AuditNamespace:
88
+ def __init__(self, client: "XyfraClient") -> None:
89
+ self._client = client
90
+
91
+ async def list(
92
+ self,
93
+ *,
94
+ tenant_id: Optional[str] = None,
95
+ since: Optional[str] = None,
96
+ cursor: Optional[str] = None,
97
+ limit: Optional[int] = None,
98
+ ) -> Any:
99
+ params: dict[str, Any] = {}
100
+ if tenant_id:
101
+ params["tenant_id"] = tenant_id
102
+ if since:
103
+ params["since"] = since
104
+ if cursor:
105
+ params["cursor"] = cursor
106
+ if limit:
107
+ params["limit"] = limit
108
+ return await self._client._get("/api/audit", params)
109
+
110
+
111
+ class _BillingNamespace:
112
+ def __init__(self, client: "XyfraClient") -> None:
113
+ self._client = client
114
+
115
+ async def get_period(self, period: Optional[str] = None, tenant_id: Optional[str] = None) -> Any:
116
+ params: dict[str, Any] = {}
117
+ if period:
118
+ params["period"] = period
119
+ if tenant_id:
120
+ params["tenantId"] = tenant_id
121
+ return await self._client._get("/api/partner-usage", params)
122
+
123
+ async def list_periods(self, cursor: Optional[str] = None, limit: Optional[int] = None) -> Any:
124
+ params: dict[str, Any] = {}
125
+ if cursor:
126
+ params["cursor"] = cursor
127
+ if limit:
128
+ params["limit"] = limit
129
+ return await self._client._get("/api/partner-usage/periods", params)
130
+
131
+
132
+ class _MetricsNamespace:
133
+ def __init__(self, client: "XyfraClient") -> None:
134
+ self._client = client
135
+
136
+ async def subscribe(
137
+ self, tenant_id: str, prometheus_remote_write_url: str, auth_header: str
138
+ ) -> Any:
139
+ return await self._client._post(
140
+ "/api/partners/me/metrics-subscriptions",
141
+ {
142
+ "tenantId": tenant_id,
143
+ "prometheusRemoteWriteUrl": prometheus_remote_write_url,
144
+ "authHeader": auth_header,
145
+ },
146
+ )
147
+
148
+ async def reactivate(self, subscription_id: str) -> Any:
149
+ return await self._client._post(
150
+ f"/api/partners/me/metrics-subscriptions/{subscription_id}/reactivate",
151
+ {},
152
+ )
153
+
154
+ async def delete(self, subscription_id: str) -> None:
155
+ response = await self._client._http.delete(
156
+ f"{self._client._base_url}/api/partners/me/metrics-subscriptions/{subscription_id}",
157
+ headers=await self._client.headers(),
158
+ )
159
+ response.raise_for_status()
160
+
161
+
162
+ class _QueryNamespace:
163
+ def __init__(self, client: "XyfraClient") -> None:
164
+ self._client = client
165
+
166
+ async def execute(self, query: str, variables: Optional[dict[str, Any]] = None) -> Any:
167
+ return await self._client._post("/graphql", {"query": query, "variables": variables or {}})
168
+
169
+
170
+ class XyfraClient:
171
+ """Hand-written thin wrapper over generated REST/GraphQL/MCP client modules."""
172
+
173
+ def __init__(self, options: XyfraClientOptions) -> None:
174
+ base_url = options.base_url.strip() if options.base_url else ""
175
+ refresh_token = options.refresh_token.strip() if options.refresh_token else ""
176
+ tenant_id = options.tenant_id.strip() if options.tenant_id else ""
177
+
178
+ if not base_url:
179
+ raise ValueError("XyfraClient: base_url is required")
180
+ if not refresh_token:
181
+ raise ValueError("XyfraClient: refresh_token is required")
182
+ if not tenant_id:
183
+ raise ValueError("XyfraClient: tenant_id is required")
184
+
185
+ self._base_url = base_url.rstrip("/")
186
+ self._tenant_id = tenant_id
187
+ self._refresh_token = refresh_token
188
+ self._sandbox_deterministic = options.sandbox_deterministic
189
+ self._traceparent = options.traceparent or str(uuid.uuid4())
190
+ self._access_token: Optional[str] = None
191
+ self._http = httpx.AsyncClient()
192
+ self._closed = False
193
+
194
+ self.query = _QueryNamespace(self)
195
+ self.tools = _ToolsNamespace(self)
196
+ self.events = _EventsNamespace(self)
197
+ self.webhooks = _WebhooksNamespace(self)
198
+ self.audit = _AuditNamespace(self)
199
+ self.billing = _BillingNamespace(self)
200
+ self.metrics = _MetricsNamespace(self)
201
+
202
+ async def refresh(self) -> None:
203
+ self._assert_open()
204
+ result = await refresh_access_token(self._base_url, self._refresh_token)
205
+ self._access_token = result["accessToken"]
206
+ self._refresh_token = result["refreshToken"]
207
+
208
+ async def access_token(self) -> str:
209
+ self._assert_open()
210
+ if self._access_token is None:
211
+ await self.refresh()
212
+ if self._access_token is None:
213
+ raise RuntimeError("refresh_failed")
214
+ return self._access_token
215
+
216
+ async def headers(self) -> dict[str, str]:
217
+ headers = {
218
+ "Authorization": f"Bearer {await self.access_token()}",
219
+ "X-Tenant-Id": self._tenant_id,
220
+ "X-Request-ID": self._traceparent,
221
+ "Content-Type": "application/json",
222
+ }
223
+ if self._sandbox_deterministic:
224
+ headers["X-Sandbox-Deterministic"] = "true"
225
+ return headers
226
+
227
+ async def close(self) -> None:
228
+ if self._closed:
229
+ return
230
+ self._closed = True
231
+ self._access_token = None
232
+ await self._http.aclose()
233
+
234
+ async def aclose(self) -> None:
235
+ await self.close()
236
+
237
+ async def __aenter__(self) -> "XyfraClient":
238
+ return self
239
+
240
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
241
+ await self.close()
242
+
243
+ @property
244
+ def is_closed(self) -> bool:
245
+ return self._closed
246
+
247
+ def _assert_open(self) -> None:
248
+ if self._closed:
249
+ raise RuntimeError("XyfraClient has been closed")
250
+
251
+ async def _get(self, path: str, params: Optional[dict[str, Any]] = None) -> Any:
252
+ response = await self._http.get(
253
+ f"{self._base_url}{path}",
254
+ headers=await self.headers(),
255
+ params=params,
256
+ )
257
+ response.raise_for_status()
258
+ content_type = response.headers.get("content-type", "")
259
+ if "application/json" in content_type:
260
+ return response.json()
261
+ return response.text
262
+
263
+ async def _post(self, path: str, body: Any) -> Any:
264
+ response = await self._http.post(
265
+ f"{self._base_url}{path}",
266
+ headers=await self.headers(),
267
+ json=body,
268
+ )
269
+ response.raise_for_status()
270
+ content_type = response.headers.get("content-type", "")
271
+ if "application/json" in content_type:
272
+ return response.json()
273
+ return response.text